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.
- package/build/src/config.js +2 -2
- package/build/src/extension/relay-server.js +1 -1
- package/build/src/fast-cdp/extension-raw.js +39 -2
- package/build/src/fast-cdp/fast-chat.js +27 -12
- package/build/src/main.js +97 -28
- package/build/src/process-lock.js +161 -2
- package/build/src/runtime-scope.js +53 -0
- package/package.json +4 -1
package/build/src/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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 = [
|
|
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(
|
|
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 || '
|
|
53
|
-
const CONNECT_NEWTAB_TIMEOUT_MS = Number(process.env.MCP_CONNECT_NEWTAB_TIMEOUT_MS || '
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
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
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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"
|