@nogataka/smart-edit 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +520 -82
- package/dist/smart-edit/agent.js +2 -1
- package/dist/smart-edit/cli.js +79 -2
- package/dist/smart-edit/dashboard.js +29 -0
- package/dist/smart-edit/instance-registry.d.ts +43 -0
- package/dist/smart-edit/instance-registry.js +269 -0
- package/dist/smart-edit/resources/config/modes/careful-editor.yml +47 -0
- package/dist/smart-edit/resources/dashboard/dashboard.js +11 -11
- package/dist/smart-edit/resources/dashboard/favicon.ico +0 -0
- package/dist/smart-edit/resources/dashboard/index.css +1 -1
- package/dist/smart-edit/resources/dashboard/index.html +2 -0
- package/dist/smart-edit/resources/dashboard/logo.png +0 -0
- package/dist/smart-edit/standalone-dashboard.d.ts +32 -0
- package/dist/smart-edit/standalone-dashboard.js +223 -0
- package/dist/smart-edit/tools/workflow_tools.d.ts +5 -1
- package/dist/smart-edit/tools/workflow_tools.js +76 -3
- package/dist/smart-edit/util/git.d.ts +30 -0
- package/dist/smart-edit/util/git.js +118 -0
- package/package.json +1 -1
package/dist/smart-edit/agent.js
CHANGED
|
@@ -20,7 +20,7 @@ import { ActivateProjectTool, GetCurrentConfigTool, RemoveProjectTool, SwitchMod
|
|
|
20
20
|
import { ReadFileTool, CreateTextFileTool, ListDirTool, FindFileTool, ReplaceRegexTool, DeleteLinesTool, ReplaceLinesTool, InsertAtLineTool, SearchForPatternTool } from './tools/file_tools.js';
|
|
21
21
|
import { WriteMemoryTool, ReadMemoryTool, ListMemoriesTool, DeleteMemoryTool } from './tools/memory_tools.js';
|
|
22
22
|
import { RestartLanguageServerTool, GetSymbolsOverviewTool, FindSymbolTool, FindReferencingSymbolsTool, ReplaceSymbolBodyTool, InsertAfterSymbolTool, InsertBeforeSymbolTool } from './tools/symbol_tools.js';
|
|
23
|
-
import { CheckOnboardingPerformedTool, OnboardingTool, ThinkAboutCollectedInformationTool, ThinkAboutTaskAdherenceTool, ThinkAboutWhetherYouAreDoneTool, SummarizeChangesTool, PrepareForNewConversationTool, InitialInstructionsTool } from './tools/workflow_tools.js';
|
|
23
|
+
import { CheckOnboardingPerformedTool, OnboardingTool, CollectProjectSymbolsTool, ThinkAboutCollectedInformationTool, ThinkAboutTaskAdherenceTool, ThinkAboutWhetherYouAreDoneTool, SummarizeChangesTool, PrepareForNewConversationTool, InitialInstructionsTool } from './tools/workflow_tools.js';
|
|
24
24
|
const { logger: log, memoryHandler: defaultMemoryHandler } = createSmartEditLogger({
|
|
25
25
|
name: 'smart-edit.agent',
|
|
26
26
|
emitToConsole: true,
|
|
@@ -62,6 +62,7 @@ const DEFAULT_TOOL_CLASSES = [
|
|
|
62
62
|
InsertBeforeSymbolTool,
|
|
63
63
|
CheckOnboardingPerformedTool,
|
|
64
64
|
OnboardingTool,
|
|
65
|
+
CollectProjectSymbolsTool,
|
|
65
66
|
ThinkAboutCollectedInformationTool,
|
|
66
67
|
ThinkAboutTaskAdherenceTool,
|
|
67
68
|
ThinkAboutWhetherYouAreDoneTool,
|
package/dist/smart-edit/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ProjectConfig, SmartEditConfig, SmartEditPaths } from './config/smart_e
|
|
|
9
9
|
import { createSmartEditLogger, setConsoleLoggingEnabled } from './util/logging.js';
|
|
10
10
|
import { SmartEditAgent } from './agent.js';
|
|
11
11
|
import { SmartEditMCPFactorySingleProcess, createSmartEditHttpServer, createSmartEditStdioServer } from './mcp.js';
|
|
12
|
+
import { DEFAULT_DASHBOARD_PORT, registerInstance, unregisterInstance, findAvailablePort } from './instance-registry.js';
|
|
12
13
|
import { ToolRegistry } from './tools/tools_base.js';
|
|
13
14
|
import { coerceLanguage } from '../smart-lsp/ls_config.js';
|
|
14
15
|
import { ensureDefaultSubprocessOptions } from '../smart-lsp/util/subprocess_util.js';
|
|
@@ -96,8 +97,25 @@ function normalizeStartMcpServerOptions(raw) {
|
|
|
96
97
|
}
|
|
97
98
|
return undefined;
|
|
98
99
|
};
|
|
100
|
+
// プロジェクトの決定ロジック:
|
|
101
|
+
// 1. --no-project が指定された場合: null(プロジェクトなしで起動)
|
|
102
|
+
// 2. --project が指定された場合: 指定されたパス
|
|
103
|
+
// 3. --project-file が指定された場合: 指定されたパス(後方互換)
|
|
104
|
+
// 4. どれも指定されていない場合: カレントディレクトリを使用
|
|
105
|
+
const resolveProject = () => {
|
|
106
|
+
if (raw.noProject === true) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (isNonEmptyString(raw.project)) {
|
|
110
|
+
return raw.project;
|
|
111
|
+
}
|
|
112
|
+
if (isNonEmptyString(raw.projectFile)) {
|
|
113
|
+
return raw.projectFile;
|
|
114
|
+
}
|
|
115
|
+
return process.cwd();
|
|
116
|
+
};
|
|
99
117
|
return {
|
|
100
|
-
project:
|
|
118
|
+
project: resolveProject(),
|
|
101
119
|
projectFile: isNonEmptyString(raw.projectFile) ? raw.projectFile : null,
|
|
102
120
|
context,
|
|
103
121
|
modes: normalizedModes.slice(),
|
|
@@ -222,6 +240,8 @@ async function handleStartMcpServer(options, projectArg) {
|
|
|
222
240
|
memoryLogHandler: agentOptions.memoryLogHandler ?? undefined
|
|
223
241
|
})
|
|
224
242
|
});
|
|
243
|
+
// Track registered instance for cleanup
|
|
244
|
+
let registeredInstance = null;
|
|
225
245
|
try {
|
|
226
246
|
const serverOptions = {
|
|
227
247
|
host: options.host,
|
|
@@ -237,11 +257,26 @@ async function handleStartMcpServer(options, projectArg) {
|
|
|
237
257
|
switch (options.transport) {
|
|
238
258
|
case 'streamable-http': {
|
|
239
259
|
const server = await createSmartEditHttpServer(factory, serverOptions);
|
|
260
|
+
const serverPort = server.url.port ? Number.parseInt(server.url.port, 10) : options.port;
|
|
240
261
|
logger.info(`Streamable HTTP MCP server started: ${server.url.href}`);
|
|
241
262
|
logger.info('Press Ctrl+C to exit.');
|
|
263
|
+
// Register instance (skip in test environment)
|
|
264
|
+
if (!process.env.SMART_EDIT_SKIP_EDITOR) {
|
|
265
|
+
registeredInstance = registerInstance({
|
|
266
|
+
port: serverPort,
|
|
267
|
+
project,
|
|
268
|
+
pid: process.pid,
|
|
269
|
+
transport: 'streamable-http'
|
|
270
|
+
});
|
|
271
|
+
logger.info(`Registered instance ${registeredInstance.id} in registry`);
|
|
272
|
+
}
|
|
242
273
|
await new Promise((resolve) => {
|
|
243
274
|
const shutdown = async () => {
|
|
244
275
|
logger.info('Stopping HTTP MCP server...');
|
|
276
|
+
if (registeredInstance) {
|
|
277
|
+
unregisterInstance(registeredInstance.id);
|
|
278
|
+
logger.info(`Unregistered instance ${registeredInstance.id} from registry`);
|
|
279
|
+
}
|
|
245
280
|
await server.close();
|
|
246
281
|
resolve();
|
|
247
282
|
};
|
|
@@ -257,6 +292,17 @@ async function handleStartMcpServer(options, projectArg) {
|
|
|
257
292
|
case 'stdio': {
|
|
258
293
|
const server = await createSmartEditStdioServer(factory, serverOptions);
|
|
259
294
|
logger.info('STDIO MCP server started. Press Ctrl+C to exit.');
|
|
295
|
+
// For stdio transport, find an available port for the dashboard API (skip in test environment)
|
|
296
|
+
if (!process.env.SMART_EDIT_SKIP_EDITOR) {
|
|
297
|
+
const dashboardPort = findAvailablePort();
|
|
298
|
+
registeredInstance = registerInstance({
|
|
299
|
+
port: dashboardPort,
|
|
300
|
+
project,
|
|
301
|
+
pid: process.pid,
|
|
302
|
+
transport: 'stdio'
|
|
303
|
+
});
|
|
304
|
+
logger.info(`Registered instance ${registeredInstance.id} in registry (dashboard port: ${dashboardPort})`);
|
|
305
|
+
}
|
|
260
306
|
await new Promise((resolve) => {
|
|
261
307
|
let settled = false;
|
|
262
308
|
const finalize = async (reason) => {
|
|
@@ -265,6 +311,10 @@ async function handleStartMcpServer(options, projectArg) {
|
|
|
265
311
|
}
|
|
266
312
|
settled = true;
|
|
267
313
|
logger.info('Stopping STDIO MCP server...');
|
|
314
|
+
if (registeredInstance) {
|
|
315
|
+
unregisterInstance(registeredInstance.id);
|
|
316
|
+
logger.info(`Unregistered instance ${registeredInstance.id} from registry`);
|
|
317
|
+
}
|
|
268
318
|
if (reason === 'signal') {
|
|
269
319
|
await server.close();
|
|
270
320
|
}
|
|
@@ -295,6 +345,11 @@ async function handleStartMcpServer(options, projectArg) {
|
|
|
295
345
|
catch (error) {
|
|
296
346
|
const message = error instanceof Error ? error.message : String(error);
|
|
297
347
|
logger.error(`Smart-Edit MCP サーバーの起動に失敗しました: ${message}`);
|
|
348
|
+
// Unregister instance on error
|
|
349
|
+
if (registeredInstance) {
|
|
350
|
+
unregisterInstance(registeredInstance.id);
|
|
351
|
+
logger.info(`Unregistered instance ${registeredInstance.id} from registry (error cleanup)`);
|
|
352
|
+
}
|
|
298
353
|
throw error;
|
|
299
354
|
}
|
|
300
355
|
finally {
|
|
@@ -379,7 +434,8 @@ export function createSmartEditCli(options = {}) {
|
|
|
379
434
|
}
|
|
380
435
|
const startMcpServerCommand = new Command('start-mcp-server')
|
|
381
436
|
.description('Smart-Edit MCP サーバーを起動します。')
|
|
382
|
-
.option('--project [project]', '
|
|
437
|
+
.option('--project [project]', '起動時にアクティブ化するプロジェクトパス。省略時はカレントディレクトリを使用。')
|
|
438
|
+
.option('--no-project', 'プロジェクトなしで起動。後から activate_project ツールで指定可能。')
|
|
383
439
|
.option('--project-file [project]', '[非推奨] --project の旧名称。')
|
|
384
440
|
.argument('[project]', '[非推奨] プロジェクトの位置引数。')
|
|
385
441
|
.option('--context <context>', 'ビルトインコンテキスト名またはカスタム YAML へのパス。')
|
|
@@ -404,11 +460,32 @@ export function createSmartEditCli(options = {}) {
|
|
|
404
460
|
await handleStartMcpServer(opts, normalizedProjectArg);
|
|
405
461
|
}
|
|
406
462
|
catch (error) {
|
|
463
|
+
// In test environment, rethrow the error so vitest can display it properly
|
|
464
|
+
// (this.error() calls process.exit which vitest intercepts, hiding the actual error)
|
|
465
|
+
if (process.env.SMART_EDIT_SKIP_EDITOR) {
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
407
468
|
const message = error instanceof Error ? error.message : String(error);
|
|
408
469
|
this.error(`${message}\n`, { exitCode: 1 });
|
|
409
470
|
}
|
|
410
471
|
});
|
|
411
472
|
program.addCommand(startMcpServerCommand);
|
|
473
|
+
const startDashboardCommand = new Command('start-dashboard')
|
|
474
|
+
.description('統合ダッシュボードを起動します(MCPサーバーなし)。複数のMCPインスタンスを一括管理できます。')
|
|
475
|
+
.option('--port <port>', 'ダッシュボードのポート番号。', (value) => parseInteger(value, '--port'), DEFAULT_DASHBOARD_PORT)
|
|
476
|
+
.action(async function () {
|
|
477
|
+
const opts = this.optsWithGlobals();
|
|
478
|
+
const port = typeof opts.port === 'number' ? opts.port : DEFAULT_DASHBOARD_PORT;
|
|
479
|
+
try {
|
|
480
|
+
const { runStandaloneDashboard } = await import('./standalone-dashboard.js');
|
|
481
|
+
await runStandaloneDashboard({ port });
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
485
|
+
this.error(`${message}\n`, { exitCode: 1 });
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
program.addCommand(startDashboardCommand);
|
|
412
489
|
const modeCommand = new Command('mode')
|
|
413
490
|
.description('Smart-Edit モードを管理します。');
|
|
414
491
|
modeCommand
|
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { createSmartEditLogger } from './util/logging.js';
|
|
9
9
|
import { SMART_EDIT_DASHBOARD_DIR } from './constants.js';
|
|
10
|
+
import { getInstances } from './instance-registry.js';
|
|
10
11
|
const { logger } = createSmartEditLogger({ name: 'smart-edit.dashboard', emitToConsole: false, level: 'info' });
|
|
11
12
|
const DEFAULT_DASHBOARD_PORT = 0x5eda;
|
|
12
13
|
const DASHBOARD_HOST = '127.0.0.1';
|
|
@@ -292,6 +293,16 @@ export class SmartEditDashboardAPI {
|
|
|
292
293
|
const method = req.method?.toUpperCase() ?? 'GET';
|
|
293
294
|
const url = parseUrl(req.url ?? '/', true);
|
|
294
295
|
const pathname = url.pathname ?? '/';
|
|
296
|
+
// Add CORS headers for cross-origin requests from multi-instance dashboard
|
|
297
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
298
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
299
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
300
|
+
// Handle CORS preflight
|
|
301
|
+
if (method === 'OPTIONS') {
|
|
302
|
+
res.statusCode = 204;
|
|
303
|
+
res.end();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
295
306
|
try {
|
|
296
307
|
if (pathname.startsWith('/dashboard')) {
|
|
297
308
|
if (method !== 'GET') {
|
|
@@ -356,6 +367,24 @@ export class SmartEditDashboardAPI {
|
|
|
356
367
|
this.shutdown();
|
|
357
368
|
this.sendJson(res, 200, { status: 'shutting down' });
|
|
358
369
|
return;
|
|
370
|
+
// Multi-instance dashboard APIs
|
|
371
|
+
case '/api/instances':
|
|
372
|
+
if (method !== 'GET') {
|
|
373
|
+
this.respondMethodNotAllowed(res);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.sendJson(res, 200, { instances: getInstances() });
|
|
377
|
+
return;
|
|
378
|
+
case '/api/instance-info':
|
|
379
|
+
if (method !== 'GET') {
|
|
380
|
+
this.respondMethodNotAllowed(res);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.sendJson(res, 200, {
|
|
384
|
+
port: this.listeningPort,
|
|
385
|
+
project: this.agent.getActiveProject()?.projectName ?? null
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
359
388
|
default:
|
|
360
389
|
this.respondNotFound(res);
|
|
361
390
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance Registry for Multi-Instance Dashboard
|
|
3
|
+
*
|
|
4
|
+
* Manages registration of multiple Smart Edit MCP server instances.
|
|
5
|
+
* Each instance registers itself when starting and unregisters when stopping.
|
|
6
|
+
* The dashboard reads this registry to discover and connect to all running instances.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_DASHBOARD_PORT = 24282;
|
|
9
|
+
export interface InstanceInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
port: number;
|
|
12
|
+
project: string | null;
|
|
13
|
+
pid: number;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
transport: 'stdio' | 'sse' | 'streamable-http';
|
|
16
|
+
}
|
|
17
|
+
export declare function generateInstanceId(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Register a new MCP server instance in the registry.
|
|
20
|
+
* This function is designed to be non-fatal - if registration fails, the instance
|
|
21
|
+
* will still work, just won't be visible in the multi-instance dashboard.
|
|
22
|
+
*/
|
|
23
|
+
export declare function registerInstance(info: Omit<InstanceInfo, 'id' | 'startedAt'>): InstanceInfo;
|
|
24
|
+
/**
|
|
25
|
+
* Unregister an MCP server instance from the registry.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unregisterInstance(instanceId: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Get all registered instances (with cleanup of dead processes).
|
|
30
|
+
*/
|
|
31
|
+
export declare function getInstances(): InstanceInfo[];
|
|
32
|
+
/**
|
|
33
|
+
* Get a specific instance by ID.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getInstance(instanceId: string): InstanceInfo | null;
|
|
36
|
+
/**
|
|
37
|
+
* Find an available port for the dashboard, starting from the default port.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findAvailablePort(startPort?: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Update instance info (e.g., when project changes).
|
|
42
|
+
*/
|
|
43
|
+
export declare function updateInstance(instanceId: string, updates: Partial<Pick<InstanceInfo, 'project'>>): void;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance Registry for Multi-Instance Dashboard
|
|
3
|
+
*
|
|
4
|
+
* Manages registration of multiple Smart Edit MCP server instances.
|
|
5
|
+
* Each instance registers itself when starting and unregisters when stopping.
|
|
6
|
+
* The dashboard reads this registry to discover and connect to all running instances.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { SMART_EDIT_MANAGED_DIR_NAME } from './constants.js';
|
|
14
|
+
import { createSmartEditLogger } from './util/logging.js';
|
|
15
|
+
const { logger } = createSmartEditLogger({ name: 'smart-edit.instance-registry', emitToConsole: false, level: 'info' });
|
|
16
|
+
export const DEFAULT_DASHBOARD_PORT = 0x5eda; // 24282
|
|
17
|
+
// Compute paths dynamically to respect runtime HOME changes (important for testing)
|
|
18
|
+
// Use process.env.HOME/USERPROFILE first as os.homedir() may cache the value
|
|
19
|
+
function getSmartEditDir() {
|
|
20
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
|
21
|
+
return path.join(home, SMART_EDIT_MANAGED_DIR_NAME);
|
|
22
|
+
}
|
|
23
|
+
function getInstancesFilePath() {
|
|
24
|
+
return path.join(getSmartEditDir(), 'instances.json');
|
|
25
|
+
}
|
|
26
|
+
function getLockFilePath() {
|
|
27
|
+
return path.join(getSmartEditDir(), 'instances.lock');
|
|
28
|
+
}
|
|
29
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
30
|
+
const LOCK_RETRY_INTERVAL_MS = 50;
|
|
31
|
+
function ensureDirectoryExists(filePath) {
|
|
32
|
+
const dir = path.dirname(filePath);
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function acquireLock() {
|
|
38
|
+
try {
|
|
39
|
+
const lockFile = getLockFilePath();
|
|
40
|
+
ensureDirectoryExists(lockFile);
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const err = error;
|
|
49
|
+
if (err.code === 'EEXIST') {
|
|
50
|
+
// Lock file exists, check if the process is still alive
|
|
51
|
+
try {
|
|
52
|
+
const lockPid = Number.parseInt(fs.readFileSync(lockFile, 'utf-8').trim(), 10);
|
|
53
|
+
if (!Number.isNaN(lockPid)) {
|
|
54
|
+
try {
|
|
55
|
+
// Check if process is alive (signal 0 doesn't kill, just checks)
|
|
56
|
+
process.kill(lockPid, 0);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Process is dead, remove stale lock
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(lockFile);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore unlink errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Can't read lock file, try to remove it
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(lockFile);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Ignore unlink errors
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Brief busy-wait before retry
|
|
81
|
+
const waitUntil = Date.now() + Math.min(LOCK_RETRY_INTERVAL_MS, LOCK_TIMEOUT_MS - (Date.now() - startTime));
|
|
82
|
+
while (Date.now() < waitUntil) {
|
|
83
|
+
// Busy wait - acceptable since lock contention should be rare
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// For other errors (EACCES, ENOENT, etc.), give up on locking
|
|
88
|
+
logger.warn(`Lock acquisition failed with error: ${err.code}`, err);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
logger.warn('Failed to acquire lock for instance registry (timeout)');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
// Catch any unexpected errors (e.g., from ensureDirectoryExists)
|
|
97
|
+
logger.warn('Unexpected error during lock acquisition', error instanceof Error ? error : undefined);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function releaseLock() {
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(getLockFilePath());
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Ignore errors when releasing lock
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function readRegistry() {
|
|
110
|
+
const instancesFile = getInstancesFilePath();
|
|
111
|
+
ensureDirectoryExists(instancesFile);
|
|
112
|
+
try {
|
|
113
|
+
if (fs.existsSync(instancesFile)) {
|
|
114
|
+
const content = fs.readFileSync(instancesFile, 'utf-8');
|
|
115
|
+
const data = JSON.parse(content);
|
|
116
|
+
if (data && typeof data === 'object' && Array.isArray(data.instances)) {
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger.warn('Failed to read instance registry, starting fresh', error instanceof Error ? error : undefined);
|
|
123
|
+
}
|
|
124
|
+
return { instances: [] };
|
|
125
|
+
}
|
|
126
|
+
function writeRegistry(data) {
|
|
127
|
+
const instancesFile = getInstancesFilePath();
|
|
128
|
+
ensureDirectoryExists(instancesFile);
|
|
129
|
+
fs.writeFileSync(instancesFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
function isProcessAlive(pid) {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function cleanupDeadInstances(data) {
|
|
141
|
+
const aliveInstances = data.instances.filter((instance) => isProcessAlive(instance.pid));
|
|
142
|
+
if (aliveInstances.length !== data.instances.length) {
|
|
143
|
+
logger.info(`Cleaned up ${data.instances.length - aliveInstances.length} dead instance(s) from registry`);
|
|
144
|
+
}
|
|
145
|
+
return { instances: aliveInstances };
|
|
146
|
+
}
|
|
147
|
+
export function generateInstanceId() {
|
|
148
|
+
return crypto.randomBytes(6).toString('hex');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Register a new MCP server instance in the registry.
|
|
152
|
+
* This function is designed to be non-fatal - if registration fails, the instance
|
|
153
|
+
* will still work, just won't be visible in the multi-instance dashboard.
|
|
154
|
+
*/
|
|
155
|
+
export function registerInstance(info) {
|
|
156
|
+
const id = generateInstanceId();
|
|
157
|
+
const instance = {
|
|
158
|
+
...info,
|
|
159
|
+
id,
|
|
160
|
+
startedAt: new Date().toISOString()
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
if (!acquireLock()) {
|
|
164
|
+
logger.warn('Failed to acquire lock for registering instance, continuing without registration');
|
|
165
|
+
return instance;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
let data = readRegistry();
|
|
169
|
+
data = cleanupDeadInstances(data);
|
|
170
|
+
// Check for duplicate port (shouldn't happen, but just in case)
|
|
171
|
+
const existingIndex = data.instances.findIndex((i) => i.port === info.port);
|
|
172
|
+
if (existingIndex !== -1) {
|
|
173
|
+
data.instances.splice(existingIndex, 1);
|
|
174
|
+
}
|
|
175
|
+
data.instances.push(instance);
|
|
176
|
+
writeRegistry(data);
|
|
177
|
+
logger.info(`Registered instance ${id} on port ${info.port} for project: ${info.project ?? '(none)'}`);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
releaseLock();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
logger.warn('Failed to register instance in registry', error instanceof Error ? error : undefined);
|
|
185
|
+
}
|
|
186
|
+
return instance;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Unregister an MCP server instance from the registry.
|
|
190
|
+
*/
|
|
191
|
+
export function unregisterInstance(instanceId) {
|
|
192
|
+
if (!acquireLock()) {
|
|
193
|
+
logger.error('Failed to acquire lock for unregistering instance');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const data = readRegistry();
|
|
198
|
+
const index = data.instances.findIndex((i) => i.id === instanceId);
|
|
199
|
+
if (index !== -1) {
|
|
200
|
+
const removed = data.instances.splice(index, 1)[0];
|
|
201
|
+
writeRegistry(data);
|
|
202
|
+
logger.info(`Unregistered instance ${instanceId} (port: ${removed.port})`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
releaseLock();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Get all registered instances (with cleanup of dead processes).
|
|
211
|
+
*/
|
|
212
|
+
export function getInstances() {
|
|
213
|
+
if (!acquireLock()) {
|
|
214
|
+
// Even if we can't acquire lock, try to read
|
|
215
|
+
const data = readRegistry();
|
|
216
|
+
return data.instances.filter((instance) => isProcessAlive(instance.pid));
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
let data = readRegistry();
|
|
220
|
+
data = cleanupDeadInstances(data);
|
|
221
|
+
writeRegistry(data);
|
|
222
|
+
return data.instances;
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
releaseLock();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Get a specific instance by ID.
|
|
230
|
+
*/
|
|
231
|
+
export function getInstance(instanceId) {
|
|
232
|
+
const instances = getInstances();
|
|
233
|
+
return instances.find((i) => i.id === instanceId) ?? null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Find an available port for the dashboard, starting from the default port.
|
|
237
|
+
*/
|
|
238
|
+
export function findAvailablePort(startPort = DEFAULT_DASHBOARD_PORT) {
|
|
239
|
+
const instances = getInstances();
|
|
240
|
+
const usedPorts = new Set(instances.map((i) => i.port));
|
|
241
|
+
let port = startPort;
|
|
242
|
+
while (usedPorts.has(port) && port <= 65535) {
|
|
243
|
+
port++;
|
|
244
|
+
}
|
|
245
|
+
return port;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Update instance info (e.g., when project changes).
|
|
249
|
+
*/
|
|
250
|
+
export function updateInstance(instanceId, updates) {
|
|
251
|
+
if (!acquireLock()) {
|
|
252
|
+
logger.error('Failed to acquire lock for updating instance');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const data = readRegistry();
|
|
257
|
+
const instance = data.instances.find((i) => i.id === instanceId);
|
|
258
|
+
if (instance) {
|
|
259
|
+
if (updates.project !== undefined) {
|
|
260
|
+
instance.project = updates.project;
|
|
261
|
+
}
|
|
262
|
+
writeRegistry(data);
|
|
263
|
+
logger.info(`Updated instance ${instanceId}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
releaseLock();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
description: Careful editing mode that respects existing code and prevents duplicate implementations
|
|
2
|
+
prompt: |
|
|
3
|
+
You are operating in careful-editor mode. This mode emphasizes avoiding duplicate implementations
|
|
4
|
+
and respecting existing code patterns in the project.
|
|
5
|
+
|
|
6
|
+
## Duplicate Prevention Rules
|
|
7
|
+
|
|
8
|
+
Before implementing anything, you MUST check for existing implementations:
|
|
9
|
+
|
|
10
|
+
1. **Read the "project-symbols" memory first**
|
|
11
|
+
- Check existing utility functions
|
|
12
|
+
- Check existing dependencies and their purposes
|
|
13
|
+
- Review common components and patterns
|
|
14
|
+
|
|
15
|
+
2. **Search for similar functionality**
|
|
16
|
+
- Before creating a new function, use `find_symbol` to search for similar names
|
|
17
|
+
- Before adding a new library, check package.json for existing dependencies
|
|
18
|
+
- Use `get_symbols_overview` to understand available utilities in relevant files
|
|
19
|
+
|
|
20
|
+
3. **Prefer existing code over new code**
|
|
21
|
+
- If an existing function can accomplish the task, use it instead of creating a new one
|
|
22
|
+
- If an existing library provides the functionality, use it instead of adding a new dependency
|
|
23
|
+
- If a similar pattern exists elsewhere in the codebase, follow that pattern
|
|
24
|
+
|
|
25
|
+
4. **Check onboarding status**
|
|
26
|
+
- If `check_onboarding_performed` returns `significantChanges: true`,
|
|
27
|
+
you should run onboarding again to refresh the project-symbols memory
|
|
28
|
+
|
|
29
|
+
## When Creating New Code
|
|
30
|
+
|
|
31
|
+
If you determine that new code is truly necessary:
|
|
32
|
+
|
|
33
|
+
1. Place it in the appropriate existing file when possible
|
|
34
|
+
2. Follow existing naming conventions in the project
|
|
35
|
+
3. Document why existing utilities couldn't be used (if applicable)
|
|
36
|
+
4. Consider whether this new code should be added to the project-symbols memory
|
|
37
|
+
|
|
38
|
+
## Examples of What to Avoid
|
|
39
|
+
|
|
40
|
+
- Creating `formatDate()` when the project already uses date-fns
|
|
41
|
+
- Adding lodash when the project already has similar utilities
|
|
42
|
+
- Creating a new `Button` component when one exists in `components/common/`
|
|
43
|
+
- Implementing custom validation when zod is already used
|
|
44
|
+
|
|
45
|
+
You have access to all editing tools, but use them thoughtfully.
|
|
46
|
+
excluded_tools: []
|
|
47
|
+
included_optional_tools: []
|