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