chrome-ai-bridge 2.3.7 → 2.3.9

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.
@@ -82,12 +82,12 @@ export function getIpcGuardConfig() {
82
82
  reservedInitSlots: raw.reservedInitSlots >= 0 ? Math.floor(raw.reservedInitSlots) : 2,
83
83
  maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
84
84
  queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 45_000,
85
- sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 120_000,
85
+ sessionIdleMs: raw.sessionIdleMs >= 0 ? Math.floor(raw.sessionIdleMs) : 1_800_000,
86
86
  startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
87
87
  startupProcessThreshold: raw.startupProcessThreshold > 0
88
88
  ? Math.floor(raw.startupProcessThreshold)
89
89
  : 8,
90
- primaryIdleMs: raw.primaryIdleMs > 0 ? Math.floor(raw.primaryIdleMs) : 300_000,
90
+ primaryIdleMs: raw.primaryIdleMs >= 0 ? Math.floor(raw.primaryIdleMs) : 0,
91
91
  execMaxConcurrency: raw.execMaxConcurrency > 0 ? Math.floor(raw.execMaxConcurrency) : 3,
92
92
  };
93
93
  }
@@ -269,7 +269,7 @@ export class RelayServer extends EventEmitter {
269
269
  * Extension polls this endpoint when user clicks the extension icon.
270
270
  */
271
271
  async startDiscoveryServer(options = {}) {
272
- const ports = [8765, 8766, 8767, 8768, 8769, 8770, 8771, 8772, 8773, 8774, 8775];
272
+ const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
273
273
  const wsUrl = this.getConnectionURL();
274
274
  for (const port of ports) {
275
275
  const started = await new Promise((resolve) => {
@@ -5,6 +5,7 @@ import { RelayServer } from '../extension/relay-server.js';
5
5
  import { logRelay, logExtension, logInfo, logError } from './mcp-logger.js';
6
6
  // Stable extension ID (from manifest.json key)
7
7
  const EXTENSION_ID = 'ibjplbopgmcacpmfpnaeoloepdhenlbm';
8
+ const ENABLE_WAKE_CONNECT_PAGE = process.env.CAI_ENABLE_WAKE_CONNECT_PAGE !== '0';
8
9
  /**
9
10
  * Get Chrome executable path for current platform
10
11
  */
@@ -91,6 +92,22 @@ function spawnChromeWithConnectUrl(connectUrl) {
91
92
  return false;
92
93
  }
93
94
  }
95
+ function buildConnectUrl(options) {
96
+ const params = new URLSearchParams();
97
+ params.set('mcpRelayUrl', options.wsUrl);
98
+ params.set('sessionId', options.sessionId);
99
+ if (options.tabUrl)
100
+ params.set('tabUrl', options.tabUrl);
101
+ if (typeof options.tabId === 'number')
102
+ params.set('tabId', String(options.tabId));
103
+ if (options.newTab)
104
+ params.set('newTab', 'true');
105
+ if (options.allowTabTakeover)
106
+ params.set('allowTabTakeover', 'true');
107
+ if (options.auto)
108
+ params.set('auto', 'true');
109
+ return `chrome-extension://${EXTENSION_ID}/ui/connect.html?${params.toString()}`;
110
+ }
94
111
  export async function connectViaExtensionRaw(options) {
95
112
  const startTime = Date.now();
96
113
  logInfo('extension-raw', 'connectViaExtensionRaw called', {
@@ -112,6 +129,15 @@ export async function connectViaExtensionRaw(options) {
112
129
  await relay.start();
113
130
  const wsUrl = relay.getConnectionURL();
114
131
  const sessionId = relay.getSessionId();
132
+ const connectUrl = buildConnectUrl({
133
+ wsUrl,
134
+ sessionId,
135
+ tabUrl: options.tabUrl,
136
+ tabId: options.tabId,
137
+ newTab: options.newTab,
138
+ allowTabTakeover: options.allowTabTakeover,
139
+ auto: true,
140
+ });
115
141
  logRelay('started', { wsUrl });
116
142
  console.error(`[fast-cdp] Relay URL: ${wsUrl} (session=${sessionId})`);
117
143
  // Save relay info for reload-extension.mjs (after discovery server starts)
@@ -140,14 +166,14 @@ export async function connectViaExtensionRaw(options) {
140
166
  }
141
167
  else {
142
168
  // Fallback: show manual URL
143
- const connectUrl = `chrome-extension://${EXTENSION_ID}/ui/connect.html?mcpRelayUrl=${encodeURIComponent(wsUrl)}&sessionId=${encodeURIComponent(sessionId)}`;
144
169
  logError('extension-raw', 'Discovery server failed', { connectUrl });
145
170
  console.error(`[fast-cdp] Discovery server failed. Please open manually:`);
146
171
  console.error(`[fast-cdp] ${connectUrl}`);
147
172
  }
148
173
  try {
149
174
  const actualTimeout = options.timeoutMs ?? 10000;
150
- const softTimeout = Math.min(5000, Math.floor(actualTimeout * 0.5));
175
+ const softTimeout = Math.max(1200, Math.min(3000, Math.floor(actualTimeout * 0.3)));
176
+ let wakeAttempted = false;
151
177
  logExtension('waiting', { timeoutMs: actualTimeout });
152
178
  await new Promise((resolve, reject) => {
153
179
  let softTimedOut = false;
@@ -157,6 +183,17 @@ export async function connectViaExtensionRaw(options) {
157
183
  waitedMs: softTimeout,
158
184
  timeoutMs: actualTimeout,
159
185
  });
186
+ if (ENABLE_WAKE_CONNECT_PAGE && !wakeAttempted) {
187
+ wakeAttempted = true;
188
+ const spawned = spawnChromeWithConnectUrl(connectUrl);
189
+ logInfo('extension-raw', 'Wake connect page attempt', {
190
+ attempted: true,
191
+ spawned,
192
+ });
193
+ if (spawned) {
194
+ console.error('[fast-cdp] Wake attempt: opened connect page in Chrome');
195
+ }
196
+ }
160
197
  }, softTimeout);
161
198
  softTimer.unref();
162
199
  const timeout = setTimeout(() => {
@@ -49,8 +49,21 @@ function setClientForAgent(kind, client, relay) {
49
49
  conn.geminiRelay = relay;
50
50
  }
51
51
  }
52
- const CONNECT_REUSE_TIMEOUT_MS = Number(process.env.MCP_CONNECT_REUSE_TIMEOUT_MS || '7000');
53
- const CONNECT_NEWTAB_TIMEOUT_MS = Number(process.env.MCP_CONNECT_NEWTAB_TIMEOUT_MS || '12000');
52
+ const CONNECT_REUSE_TIMEOUT_MS = Number(process.env.MCP_CONNECT_REUSE_TIMEOUT_MS || '12000');
53
+ const CONNECT_NEWTAB_TIMEOUT_MS = Number(process.env.MCP_CONNECT_NEWTAB_TIMEOUT_MS || '20000');
54
+ const MCP_TOOL_BUDGET_MS = Number(process.env.CAI_MCP_TOOL_BUDGET_MS || '50000');
55
+ const RESPONSE_WAIT_MAX_MS = Number(process.env.CAI_RESPONSE_WAIT_MAX_MS || '40000');
56
+ const BUDGET_RESERVE_MS = Number(process.env.CAI_MCP_BUDGET_RESERVE_MS || '3000');
57
+ function getRemainingBudgetMs(startMs) {
58
+ return MCP_TOOL_BUDGET_MS - (nowMs() - startMs) - BUDGET_RESERVE_MS;
59
+ }
60
+ function getResponseWaitBudgetMs(startMs, ceilingMs, stage) {
61
+ const remaining = getRemainingBudgetMs(startMs);
62
+ if (remaining <= 1000) {
63
+ throw new Error(`MCP_TOOL_BUDGET_EXCEEDED: stage=${stage} budgetMs=${MCP_TOOL_BUDGET_MS} reserveMs=${BUDGET_RESERVE_MS}`);
64
+ }
65
+ return Math.max(1000, Math.min(ceilingMs, remaining));
66
+ }
54
67
  function nowMs() {
55
68
  return Date.now();
56
69
  }
@@ -994,9 +1007,8 @@ async function askChatGPTFastInternal(question, debug) {
994
1007
  }
995
1008
  const tWaitResp = nowMs();
996
1009
  console.error('[ChatGPT] Waiting for response (using stop button detection)...');
997
- // 新方式: ポーリングで状態を監視(診断ログ付き)
998
- // 長い応答に対応するため8分(480秒)に設定
999
- const maxWaitMs = 480000;
1010
+ // 60秒 caller deadline を超えないよう、残り予算内で待機する。
1011
+ const maxWaitMs = getResponseWaitBudgetMs(t0, RESPONSE_WAIT_MAX_MS, 'chatgpt-response');
1000
1012
  const pollIntervalMs = 1000;
1001
1013
  const startWait = Date.now();
1002
1014
  let lastLoggedState = '';
@@ -1350,7 +1362,8 @@ async function askChatGPTFastInternal(question, debug) {
1350
1362
  })()
1351
1363
  `);
1352
1364
  console.error(`[ChatGPT] Timeout - final state: ${JSON.stringify(finalState)}`);
1353
- throw new Error(`Timed out waiting for ChatGPT response (8min). Final state: ${JSON.stringify(finalState)}`);
1365
+ await resetConnection('chatgpt');
1366
+ throw new Error(`Timed out waiting for ChatGPT response (${maxWaitMs}ms). Final state: ${JSON.stringify(finalState)}`);
1354
1367
  }
1355
1368
  // ChatGPT 5.2 Thinking モデル対応:
1356
1369
  // 回答が「思考」として折りたたまれている場合は展開してからテキストを取得
@@ -1388,7 +1401,7 @@ async function askChatGPTFastInternal(question, debug) {
1388
1401
  // 回答完了後、DOM安定化のための追加待機
1389
1402
  // ChatGPT Thinkingモードでは、停止ボタン消失後も最終回答がレンダリングされるまで遅延がある
1390
1403
  // 回答テキストが存在するまでポーリングで待機
1391
- const maxWaitForText = 120000; // 最大120秒(Thinkingモード対応:長い思考の後の回答レンダリングを待機)
1404
+ const maxWaitForText = getResponseWaitBudgetMs(t0, 15000, 'chatgpt-finalize');
1392
1405
  const pollInterval = 200;
1393
1406
  const waitStart = Date.now();
1394
1407
  let hasResponseText = false;
@@ -2065,7 +2078,8 @@ async function askChatGPTViaDriver(question, debug) {
2065
2078
  timings.sendMs = nowMs() - tSend;
2066
2079
  // 応答待機
2067
2080
  const tWaitResp = nowMs();
2068
- await driver.waitForResponse({ maxWaitMs: 480000 });
2081
+ const driverWaitBudgetMs = getResponseWaitBudgetMs(t0, RESPONSE_WAIT_MAX_MS, 'chatgpt-driver-response');
2082
+ await driver.waitForResponse({ maxWaitMs: driverWaitBudgetMs });
2069
2083
  timings.waitResponseMs = nowMs() - tWaitResp;
2070
2084
  // 応答抽出
2071
2085
  const extractResult = await driver.extractResponse({ debug });
@@ -2132,7 +2146,8 @@ async function askGeminiViaDriver(question, debug) {
2132
2146
  timings.sendMs = nowMs() - tSend;
2133
2147
  // 応答待機
2134
2148
  const tWaitResp = nowMs();
2135
- await driver.waitForResponse({ maxWaitMs: 480000 });
2149
+ const driverWaitBudgetMs = getResponseWaitBudgetMs(t0, RESPONSE_WAIT_MAX_MS, 'gemini-driver-response');
2150
+ await driver.waitForResponse({ maxWaitMs: driverWaitBudgetMs });
2136
2151
  timings.waitResponseMs = nowMs() - tWaitResp;
2137
2152
  // 応答抽出
2138
2153
  const extractResult = await driver.extractResponse({ debug });
@@ -2661,8 +2676,7 @@ async function askGeminiFastInternal(question, debug) {
2661
2676
  const tWaitResp = nowMs();
2662
2677
  console.error('[Gemini] Waiting for response completion (polling with diagnostics)...');
2663
2678
  // ChatGPT側と同様のポーリングループで応答完了を検出
2664
- // 長い応答に対応するため8分(480秒)に設定
2665
- const maxWaitMs = 480000;
2679
+ const maxWaitMs = getResponseWaitBudgetMs(t0, RESPONSE_WAIT_MAX_MS, 'gemini-response');
2666
2680
  const pollIntervalMs = 1000;
2667
2681
  const startWait = Date.now();
2668
2682
  let lastLoggedState = '';
@@ -2859,7 +2873,8 @@ async function askGeminiFastInternal(question, debug) {
2859
2873
  })()
2860
2874
  `);
2861
2875
  console.error(`[Gemini] Timeout - final state: ${JSON.stringify(finalState)}`);
2862
- throw new Error(`Timed out waiting for Gemini response (8min). sawStopButton=${sawStopButton}, textStableCount=${textStableCount}. Final state: ${JSON.stringify(finalState)}`);
2876
+ await resetConnection('gemini');
2877
+ throw new Error(`Timed out waiting for Gemini response (${maxWaitMs}ms). sawStopButton=${sawStopButton}, textStableCount=${textStableCount}. Final state: ${JSON.stringify(finalState)}`);
2863
2878
  }
2864
2879
  // 重要: タブをフォアグラウンドに持ってくる(バックグラウンドタブ対策)
2865
2880
  // GeminiもChatGPTと同様、バックグラウンドタブではDOMの状態が正しく取得できない
package/build/src/main.js CHANGED
@@ -33,7 +33,7 @@ import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
33
33
  import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
34
34
  import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
35
35
  import { getIpcGuardConfig, getSessionConfig, IPC_CONFIG } from './config.js';
36
- import { releaseLock, tryAcquireLockSafe, checkExistingPrimary, updateLockPort } from './process-lock.js';
36
+ import { releaseLock, tryAcquireLockSafe, checkExistingPrimary, updateLockPort, terminatePrimaryProcess, getLockNamespace, cleanupOrphanBridgeProcesses, } from './process-lock.js';
37
37
  import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
38
38
  function readPackageJson() {
39
39
  const currentDir = import.meta.dirname;
@@ -54,6 +54,7 @@ const version = readPackageJson().version ?? 'unknown';
54
54
  export const args = parseArguments(version);
55
55
  const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
56
56
  logger(`Starting Chrome AI Bridge v${version} (Extension-only mode)`);
57
+ logger(`[main] Runtime lock namespace: ${getLockNamespace()}`);
57
58
  // Initialize agent ID for Agent Teams support
58
59
  const agentId = generateAgentId();
59
60
  setAgentId(agentId);
@@ -65,9 +66,11 @@ const MAX_STARTUP_ATTEMPTS = 5;
65
66
  const BASE_DELAY_MS = 300;
66
67
  const HEALTH_CHECK_RETRIES = 3;
67
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');
68
70
  const ipcGuardConfig = getIpcGuardConfig();
69
71
  const instanceId = randomUUID();
70
72
  let becamePrimary = false;
73
+ let attemptedPrimarySelfHeal = false;
71
74
  let stdinClosed = false;
72
75
  let getActiveIpcSessionCount = () => 0;
73
76
  function countLocalBridgeInstances() {
@@ -116,6 +119,29 @@ for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
116
119
  }
117
120
  }
118
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
+ }
119
145
  }
120
146
  // 3. Neither Primary nor Proxy — backoff with jitter and retry
121
147
  if (attempt < MAX_STARTUP_ATTEMPTS - 1) {
@@ -173,6 +199,37 @@ Make sure the chrome-ai-bridge extension is installed and Chrome is running.
173
199
  Available tools: ask_chatgpt_web, ask_gemini_web, ask_chatgpt_gemini_web, take_cdp_snapshot, get_page_dom, ask_gemini_image`);
174
200
  };
175
201
  const toolMutex = new Mutex(ipcGuardConfig.execMaxConcurrency);
202
+ const TOOL_SELF_CLEANUP_ENABLED = process.env.CAI_TOOL_SELF_CLEANUP_ENABLED !== '0';
203
+ const TOOL_SELF_CLEANUP_INTERVAL_MS = Math.max(5000, Number(process.env.CAI_TOOL_SELF_CLEANUP_INTERVAL_MS || '60000'));
204
+ let lastToolSelfCleanupAt = 0;
205
+ let toolSelfCleanupInFlight = null;
206
+ async function maybeRunToolSelfCleanup() {
207
+ if (!TOOL_SELF_CLEANUP_ENABLED) {
208
+ return;
209
+ }
210
+ const now = Date.now();
211
+ if (now - lastToolSelfCleanupAt < TOOL_SELF_CLEANUP_INTERVAL_MS) {
212
+ return;
213
+ }
214
+ if (toolSelfCleanupInFlight) {
215
+ await toolSelfCleanupInFlight;
216
+ return;
217
+ }
218
+ lastToolSelfCleanupAt = now;
219
+ toolSelfCleanupInFlight = (async () => {
220
+ const cleaned = await cleanupOrphanBridgeProcesses();
221
+ if (cleaned > 0) {
222
+ logger(`[main] Tool-triggered orphan cleanup removed ${cleaned} process(es).`);
223
+ }
224
+ })()
225
+ .catch(error => {
226
+ logger(`[main] Tool-triggered orphan cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
227
+ })
228
+ .finally(() => {
229
+ toolSelfCleanupInFlight = null;
230
+ });
231
+ await toolSelfCleanupInFlight;
232
+ }
176
233
  function registerTool(tool) {
177
234
  server.registerTool(tool.name, {
178
235
  description: tool.description,
@@ -180,6 +237,7 @@ function registerTool(tool) {
180
237
  annotations: tool.annotations,
181
238
  }, async (params) => {
182
239
  touchPrimaryActivity();
240
+ await maybeRunToolSelfCleanup();
183
241
  const guard = await toolMutex.acquire();
184
242
  try {
185
243
  logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
@@ -315,26 +373,31 @@ logDisclaimers();
315
373
  initQueue.push({ resolve, reject, timeout });
316
374
  });
317
375
  }
318
- const idleCleanupTimer = setInterval(async () => {
319
- const now = Date.now();
320
- const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
321
- .filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
322
- .map(([sessionId]) => sessionId);
323
- if (staleSessionIds.length === 0) {
324
- return;
325
- }
326
- logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
327
- for (const staleSessionId of staleSessionIds) {
328
- try {
329
- await ipcTransports[staleSessionId]?.close();
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) {
383
+ return;
330
384
  }
331
- catch {
332
- // Ignore transport close errors and continue cleanup.
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);
333
394
  }
334
- cleanupIpcSession(staleSessionId);
335
- }
336
- }, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
337
- idleCleanupTimer.unref();
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
+ }
338
401
  const ipcServer = http.createServer(async (req, res) => {
339
402
  if (!req.url || !req.method) {
340
403
  res.writeHead(400).end();
@@ -347,6 +410,7 @@ logDisclaimers();
347
410
  status: 'ok',
348
411
  pid: process.pid,
349
412
  version,
413
+ namespace: getLockNamespace(),
350
414
  instanceId,
351
415
  activeSessions: getActiveSessionCount(),
352
416
  queuedInitializations: initQueue.length,
@@ -504,15 +568,20 @@ logDisclaimers();
504
568
  });
505
569
  ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
506
570
  // Primary idle auto-exit: exit when no activity and no active IPC sessions
507
- const primaryIdleCheckTimer = setInterval(() => {
508
- const activeSessionCount = getActiveSessionCount();
509
- if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs &&
510
- activeSessionCount === 0) {
511
- logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s with 0 active sessions. Auto-exiting.`);
512
- shutdown('idle timeout');
513
- }
514
- }, 30_000);
515
- primaryIdleCheckTimer.unref();
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');
578
+ }
579
+ }, 30_000);
580
+ primaryIdleCheckTimer.unref();
581
+ }
582
+ else {
583
+ logger('[main] Primary idle auto-exit is disabled (CAI_PRIMARY_IDLE_MS=0).');
584
+ }
516
585
  }
517
586
  // Graceful shutdown handler with timeout
518
587
  // Based on review: タイムアウト必須、強制終了タイマー必要
@@ -10,7 +10,10 @@ import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { logger } from './logger.js';
13
- const LOCK_DIR = path.join(os.homedir(), '.cache', 'chrome-ai-bridge');
13
+ import { getRuntimeNamespace } from './runtime-scope.js';
14
+ const RUNTIME_NAMESPACE = getRuntimeNamespace();
15
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
16
+ const LOCK_DIR = path.join(os.homedir(), '.cache', 'chrome-ai-bridge', RUNTIME_NAMESPACE);
14
17
  const LOCK_FILE = path.join(LOCK_DIR, 'mcp.lock');
15
18
  let lockFd = null;
16
19
  function isProcessAlive(pid) {
@@ -25,6 +28,23 @@ function isProcessAlive(pid) {
25
28
  function sleep(ms) {
26
29
  return new Promise(resolve => setTimeout(resolve, ms));
27
30
  }
31
+ function collectDescendants(rows, rootPid) {
32
+ const descendants = new Set();
33
+ const stack = [rootPid];
34
+ while (stack.length > 0) {
35
+ const current = stack.pop();
36
+ if (typeof current !== 'number') {
37
+ continue;
38
+ }
39
+ for (const row of rows) {
40
+ if (row.ppid === current && !descendants.has(row.pid)) {
41
+ descendants.add(row.pid);
42
+ stack.push(row.pid);
43
+ }
44
+ }
45
+ }
46
+ return descendants;
47
+ }
28
48
  /**
29
49
  * Read lock file content. Supports both JSON (new) and plain PID (legacy).
30
50
  */
@@ -69,7 +89,13 @@ export function checkExistingPrimary() {
69
89
  logger(`[process-lock] Stale lock (pid=${info.pid}, not running).`);
70
90
  return null;
71
91
  }
72
- return { alive: true, port: info.port, instanceId: info.instanceId };
92
+ return {
93
+ pid: info.pid,
94
+ alive: true,
95
+ port: info.port,
96
+ instanceId: info.instanceId,
97
+ startedAt: info.startedAt,
98
+ };
73
99
  }
74
100
  /**
75
101
  * Try to create lock file exclusively (wx flag).
@@ -225,6 +251,139 @@ export function releaseLock() {
225
251
  catch { /* ignore */ }
226
252
  logger('[process-lock] Lock released.');
227
253
  }
254
+ /**
255
+ * Best-effort termination of a potentially unhealthy primary.
256
+ * Returns true if the process no longer exists after termination attempts.
257
+ */
258
+ export async function terminatePrimaryProcess(pid) {
259
+ if (!Number.isFinite(pid) || pid <= 0)
260
+ return false;
261
+ if (!isProcessAlive(pid))
262
+ return true;
263
+ logger(`[process-lock] Attempting self-heal termination for pid=${pid} (SIGTERM).`);
264
+ try {
265
+ process.kill(pid, 'SIGTERM');
266
+ }
267
+ catch {
268
+ // Process may already be gone.
269
+ }
270
+ await sleep(1500);
271
+ if (!isProcessAlive(pid)) {
272
+ logger(`[process-lock] Self-heal termination succeeded for pid=${pid} after SIGTERM.`);
273
+ return true;
274
+ }
275
+ logger(`[process-lock] pid=${pid} still alive after SIGTERM. Sending SIGKILL.`);
276
+ try {
277
+ process.kill(pid, 'SIGKILL');
278
+ }
279
+ catch {
280
+ // ignore
281
+ }
282
+ await sleep(300);
283
+ const dead = !isProcessAlive(pid);
284
+ logger(dead
285
+ ? `[process-lock] Self-heal termination succeeded for pid=${pid} after SIGKILL.`
286
+ : `[process-lock] Self-heal termination failed for pid=${pid}.`);
287
+ return dead;
288
+ }
289
+ export function getLockNamespace() {
290
+ return RUNTIME_NAMESPACE;
291
+ }
292
+ export function getLockFilePath() {
293
+ return LOCK_FILE;
294
+ }
295
+ function listProcessRows() {
296
+ try {
297
+ const output = execFileSync('ps', ['-ax', '-o', 'pid=,ppid=,command='], {
298
+ encoding: 'utf-8',
299
+ timeout: 5000,
300
+ });
301
+ return output
302
+ .trim()
303
+ .split('\n')
304
+ .map(line => line.trim())
305
+ .filter(Boolean)
306
+ .map(line => {
307
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
308
+ if (!match)
309
+ return null;
310
+ return {
311
+ pid: Number(match[1]),
312
+ ppid: Number(match[2]),
313
+ command: match[3],
314
+ };
315
+ })
316
+ .filter((row) => !!row && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
317
+ }
318
+ catch {
319
+ return [];
320
+ }
321
+ }
322
+ /**
323
+ * Best-effort stale process sweep for this project path.
324
+ *
325
+ * Targets only orphaned bridge processes (ppid=1) for the same project root.
326
+ * The current process and lock-primary family are preserved.
327
+ *
328
+ * Returns number of terminated orphan processes.
329
+ */
330
+ export async function cleanupOrphanBridgeProcesses(projectRootHint = PROJECT_ROOT) {
331
+ const rows = listProcessRows();
332
+ if (rows.length === 0)
333
+ return 0;
334
+ const normalizedRoot = path.resolve(projectRootHint);
335
+ const bridgeRows = rows.filter(row => row.command.includes(normalizedRoot) &&
336
+ (row.command.includes('/build/src/main.js') ||
337
+ row.command.includes('/scripts/cli.mjs')));
338
+ if (bridgeRows.length === 0)
339
+ return 0;
340
+ const protectedPids = new Set([process.pid, process.ppid]);
341
+ const lockInfo = readLockInfo();
342
+ if (lockInfo?.pid && isProcessAlive(lockInfo.pid)) {
343
+ protectedPids.add(lockInfo.pid);
344
+ for (const pid of collectDescendants(rows, lockInfo.pid)) {
345
+ protectedPids.add(pid);
346
+ }
347
+ }
348
+ else if (lockInfo?.pid && !isProcessAlive(lockInfo.pid)) {
349
+ try {
350
+ fs.unlinkSync(LOCK_FILE);
351
+ logger(`[process-lock] Removed stale lock during orphan sweep (pid=${lockInfo.pid}).`);
352
+ }
353
+ catch {
354
+ // ignore lock unlink failures
355
+ }
356
+ }
357
+ const targets = bridgeRows
358
+ .filter(row => row.ppid === 1)
359
+ .map(row => row.pid)
360
+ .filter(pid => !protectedPids.has(pid));
361
+ if (targets.length === 0)
362
+ return 0;
363
+ const dedupedTargets = Array.from(new Set(targets));
364
+ logger(`[process-lock] Cleaning orphan bridge process(es): ${dedupedTargets.join(', ')}`);
365
+ for (const pid of dedupedTargets) {
366
+ try {
367
+ process.kill(pid, 'SIGTERM');
368
+ }
369
+ catch {
370
+ // already gone
371
+ }
372
+ }
373
+ await sleep(500);
374
+ for (const pid of dedupedTargets) {
375
+ if (!isProcessAlive(pid)) {
376
+ continue;
377
+ }
378
+ try {
379
+ process.kill(pid, 'SIGKILL');
380
+ }
381
+ catch {
382
+ // ignore
383
+ }
384
+ }
385
+ return dedupedTargets.length;
386
+ }
228
387
  /**
229
388
  * Kill all sibling chrome-ai-bridge processes (bulk cleanup).
230
389
  *
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Runtime scope utilities.
3
+ *
4
+ * By default, scope is derived from the current git root (or cwd fallback),
5
+ * then hashed into a stable namespace.
6
+ * This isolates lock files between different projects using the same MCP.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import path from 'node:path';
10
+ import process from 'node:process';
11
+ import { execFileSync } from 'node:child_process';
12
+ function detectScopePath() {
13
+ const envScope = String(process.env.CAI_SCOPE_PATH || '').trim();
14
+ if (envScope) {
15
+ return path.resolve(envScope);
16
+ }
17
+ try {
18
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
19
+ cwd: process.cwd(),
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'ignore'],
22
+ timeout: 1500,
23
+ }).trim();
24
+ if (gitRoot) {
25
+ return path.resolve(gitRoot);
26
+ }
27
+ }
28
+ catch {
29
+ // Not a git repo or git unavailable; fall back to cwd.
30
+ }
31
+ return path.resolve(process.cwd());
32
+ }
33
+ function normalizeNamespace(value) {
34
+ const trimmed = value.trim().toLowerCase();
35
+ const normalized = trimmed.replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-');
36
+ return normalized.replace(/^-+|-+$/g, '');
37
+ }
38
+ export function getRuntimeNamespace() {
39
+ const envNamespace = String(process.env.CAI_NAMESPACE || '').trim();
40
+ if (envNamespace) {
41
+ const explicit = normalizeNamespace(envNamespace);
42
+ if (explicit) {
43
+ return explicit;
44
+ }
45
+ }
46
+ const scopePath = detectScopePath();
47
+ const hash = crypto
48
+ .createHash('sha1')
49
+ .update(scopePath)
50
+ .digest('hex')
51
+ .slice(0, 12);
52
+ return `scope-${hash}`;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-ai-bridge",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
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",
@@ -37,6 +37,9 @@
37
37
  "test:mcp:parallel": "npm run build && node scripts/test-mcp.mjs --parallel",
38
38
  "test:network": "npm run build && node scripts/test-network-intercept.mjs",
39
39
  "test": "npm run build:noext && node scripts/test-mcp.mjs --tools-only",
40
+ "discord:collect": "node scripts/discord-readonly-collector.mjs",
41
+ "discord:preflight": "node scripts/discord-readonly-preflight.mjs",
42
+ "discord:status": "node scripts/discord-readonly-status.mjs",
40
43
  "docs": "npm run build:noext && node --experimental-strip-types scripts/generate-docs.ts",
41
44
  "generate-docs": "npm run docs",
42
45
  "sync-server-json-version": "node --experimental-strip-types scripts/sync-server-json-version.ts"