@datafrog-io/n2n-nexus 0.3.4 → 0.3.6
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/config/cli.js +11 -0
- package/build/config/index.js +81 -0
- package/build/config/paths.js +38 -0
- package/build/constants.js +22 -0
- package/build/index.js +85 -212
- package/build/network/election.js +94 -0
- package/build/network/guest.js +91 -0
- package/build/network/host.js +79 -0
- package/build/network/index.js +6 -0
- package/build/resources/index.js +6 -5
- package/build/storage/index.js +15 -14
- package/build/storage/meetings.js +12 -11
- package/build/storage/sqlite.js +1 -1
- package/build/storage/tasks.js +2 -1
- package/build/tools/handlers.js +1 -1
- package/build/utils/auth.js +1 -1
- package/docs/CHANGELOG_zh.md +5 -0
- package/package.json +2 -2
- package/build/config.js +0 -243
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Argument Parsing
|
|
3
|
+
*/
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
export function getArg(k) {
|
|
6
|
+
const i = args.indexOf(k);
|
|
7
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : "";
|
|
8
|
+
}
|
|
9
|
+
export function hasFlag(k) {
|
|
10
|
+
return args.includes(k) || args.includes(k.charAt(1) === "-" ? k : k.substring(0, 2));
|
|
11
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Module - Central Configuration
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { FILE_ENCODING, PACKAGE_JSON } from "../constants.js";
|
|
8
|
+
import { getArg, hasFlag } from "./cli.js";
|
|
9
|
+
import { getRootPath } from "./paths.js";
|
|
10
|
+
import { isHostAutoElection } from "../network/election.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// Load version from package.json
|
|
13
|
+
const pkgPath = path.resolve(__dirname, `../../${PACKAGE_JSON}`);
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, FILE_ENCODING));
|
|
15
|
+
export { pkg };
|
|
16
|
+
// --- CLI Commands Handlers ---
|
|
17
|
+
if (hasFlag("--help") || hasFlag("-h")) {
|
|
18
|
+
console.error(`
|
|
19
|
+
n2ns Nexus 🚀 - Local Digital Asset Hub (MCP Server) v${pkg.version}
|
|
20
|
+
|
|
21
|
+
USAGE:
|
|
22
|
+
npx -y @datafrog-io/n2n-nexus [options]
|
|
23
|
+
|
|
24
|
+
DESCRIPTION:
|
|
25
|
+
A local-first project management and collaboration hub designed for
|
|
26
|
+
multi-AI assistant coordination across different IDEs (Cursor, VS Code, etc.).
|
|
27
|
+
|
|
28
|
+
OPTIONS:
|
|
29
|
+
--root <path> Directory for data persistence. Default: ~/.n2n-nexus
|
|
30
|
+
--version, -v Show version number.
|
|
31
|
+
--help, -h Show this message.
|
|
32
|
+
|
|
33
|
+
MCP CONFIG EXAMPLE (claude_desktop_config.json):
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"n2n-nexus": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "@datafrog-io/n2n-nexus", "--root", "/path/to/storage"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ENVIRONMENT VARIABLES:
|
|
44
|
+
NEXUS_ROOT Override default storage path.
|
|
45
|
+
`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (hasFlag("--version") || hasFlag("-v")) {
|
|
49
|
+
console.error(pkg.version);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Automatic Project Name Detection
|
|
54
|
+
*/
|
|
55
|
+
function getAutoProjectName() {
|
|
56
|
+
try {
|
|
57
|
+
const localPkgPath = path.join(process.cwd(), PACKAGE_JSON);
|
|
58
|
+
if (fs.existsSync(localPkgPath)) {
|
|
59
|
+
const localPkg = JSON.parse(fs.readFileSync(localPkgPath, FILE_ENCODING));
|
|
60
|
+
if (localPkg.name)
|
|
61
|
+
return localPkg.name.split("/").pop() || localPkg.name;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
const base = path.basename(process.cwd()) || "Assistant";
|
|
66
|
+
const suffix = Math.random().toString(36).substring(2, 6);
|
|
67
|
+
return `${base}-${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
// Run election at module load
|
|
70
|
+
const rootPath = getRootPath();
|
|
71
|
+
const election = await isHostAutoElection(rootPath);
|
|
72
|
+
const projectName = getAutoProjectName();
|
|
73
|
+
export const hostServer = election.server;
|
|
74
|
+
export const CONFIG = {
|
|
75
|
+
instanceId: getArg("--id") || projectName,
|
|
76
|
+
isHost: election.isHost,
|
|
77
|
+
rootStorage: election.isHost ? rootPath : (election.rootStorage || rootPath),
|
|
78
|
+
port: election.port
|
|
79
|
+
};
|
|
80
|
+
// Re-export for Guest reconnection
|
|
81
|
+
export { isHostAutoElection } from "../network/election.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Resolution Logic
|
|
3
|
+
*/
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import { SERVICE_NAME } from "../constants.js";
|
|
7
|
+
import { getArg } from "./cli.js";
|
|
8
|
+
/**
|
|
9
|
+
* Normalize and resolve the root storage path
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRootPath(inputPath) {
|
|
12
|
+
// Priority: CLI --root > ENV NEXUS_ROOT > System Default
|
|
13
|
+
let root = inputPath || process.env.NEXUS_ROOT || getDefaultDataDir();
|
|
14
|
+
// Resolve ~ to home directory
|
|
15
|
+
if (root.startsWith("~")) {
|
|
16
|
+
root = path.join(os.homedir(), root.slice(1));
|
|
17
|
+
}
|
|
18
|
+
// Cross-platform adaptation (WSL <-> Windows)
|
|
19
|
+
if (process.platform === "linux" && /^[a-zA-Z]:[/\\]/.test(root)) {
|
|
20
|
+
const drive = root[0].toLowerCase();
|
|
21
|
+
root = `/mnt/${drive}${root.slice(2).replace(/\\/g, "/")}`;
|
|
22
|
+
}
|
|
23
|
+
return path.resolve(root);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get the default data directory
|
|
27
|
+
*/
|
|
28
|
+
export function getDefaultDataDir() {
|
|
29
|
+
const home = os.homedir();
|
|
30
|
+
// Use ~/.n2n-nexus for all platforms (developer-friendly convention)
|
|
31
|
+
return path.join(home, `.${SERVICE_NAME}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the root storage path from CLI or environment
|
|
35
|
+
*/
|
|
36
|
+
export function getRootPath() {
|
|
37
|
+
return normalizeRootPath(getArg("--root"));
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Network Constants
|
|
3
|
+
*
|
|
4
|
+
* Centralized configuration for network-related settings.
|
|
5
|
+
*/
|
|
6
|
+
// Service identification
|
|
7
|
+
export const SERVICE_NAME = "n2n-nexus";
|
|
8
|
+
// Host address for binding and connecting
|
|
9
|
+
// Use "0.0.0.0" to allow connections from any interface
|
|
10
|
+
// Use "127.0.0.1" to restrict to localhost only
|
|
11
|
+
export const NEXUS_HOST = "0.0.0.0";
|
|
12
|
+
// Port range for auto-election
|
|
13
|
+
export const PORT_RANGE_START = 5688;
|
|
14
|
+
export const PORT_RANGE_END = 5800;
|
|
15
|
+
// Timeouts (milliseconds)
|
|
16
|
+
export const HANDSHAKE_TIMEOUT = 500;
|
|
17
|
+
export const HEARTBEAT_INTERVAL = 30000;
|
|
18
|
+
// Task cleanup
|
|
19
|
+
export const TASK_CLEANUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
20
|
+
// File I/O
|
|
21
|
+
export const FILE_ENCODING = "utf-8";
|
|
22
|
+
export const PACKAGE_JSON = "package.json";
|
package/build/index.js
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* n2ns Nexus: Unified Project Asset & Collaboration Hub
|
|
4
|
+
*
|
|
5
|
+
* Modular MCP Server for multi-AI assistant coordination.
|
|
6
|
+
*/
|
|
2
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
8
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import {
|
|
6
|
-
import { readFileSync } from "fs";
|
|
7
|
-
import { join } from "path";
|
|
8
|
-
import { fileURLToPath } from "url";
|
|
9
|
-
import http from "http";
|
|
10
|
-
import { CONFIG, hostServer } from "./config.js";
|
|
9
|
+
import { CONFIG, hostServer, pkg } from "./config/index.js";
|
|
11
10
|
import { StorageManager } from "./storage/index.js";
|
|
12
11
|
import { TOOL_DEFINITIONS, handleToolCall } from "./tools/index.js";
|
|
13
12
|
import { listResources, getResourceContent } from "./resources/index.js";
|
|
14
13
|
import { sanitizeErrorMessage } from "./utils/error.js";
|
|
15
14
|
import { checkHostPermission } from "./utils/auth.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* n2ns Nexus: Unified Project Asset & Collaboration Hub
|
|
20
|
-
*
|
|
21
|
-
* Modular MCP Server for multi-AI assistant coordination.
|
|
22
|
-
*/
|
|
15
|
+
import { SERVICE_NAME } from "./constants.js";
|
|
16
|
+
import { startHost, startGuest } from "./network/index.js";
|
|
23
17
|
class NexusServer {
|
|
24
18
|
server;
|
|
25
19
|
currentProject = null;
|
|
26
20
|
sseTransports = new Map();
|
|
27
21
|
constructor() {
|
|
28
|
-
this.server = new Server({ name:
|
|
22
|
+
this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
|
|
29
23
|
this.setupHandlers();
|
|
30
24
|
}
|
|
31
25
|
setupHandlers() {
|
|
@@ -41,96 +35,101 @@ class NexusServer {
|
|
|
41
35
|
});
|
|
42
36
|
// --- Resource Reading ---
|
|
43
37
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
44
|
-
const { uri } = request.params;
|
|
45
38
|
try {
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return { contents: [{ uri, mimeType: content.mimeType, text: content.text }] };
|
|
39
|
+
const result = await getResourceContent(request.params.uri, this.currentProject);
|
|
40
|
+
if (!result) {
|
|
41
|
+
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${request.params.uri}`);
|
|
50
42
|
}
|
|
51
|
-
|
|
43
|
+
return { contents: [{ uri: request.params.uri, ...result }] };
|
|
52
44
|
}
|
|
53
45
|
catch (error) {
|
|
54
|
-
if (error instanceof McpError)
|
|
55
|
-
throw error;
|
|
56
46
|
const msg = error instanceof Error ? error.message : String(error);
|
|
57
|
-
throw new McpError(ErrorCode.InternalError, `Nexus
|
|
47
|
+
throw new McpError(ErrorCode.InternalError, `Nexus Read Error: ${sanitizeErrorMessage(msg)}`);
|
|
58
48
|
}
|
|
59
49
|
});
|
|
60
50
|
// --- Tool Listing ---
|
|
61
|
-
this.server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
62
|
-
tools: TOOL_DEFINITIONS
|
|
63
|
-
})
|
|
64
|
-
// --- Tool
|
|
51
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
52
|
+
return { tools: TOOL_DEFINITIONS };
|
|
53
|
+
});
|
|
54
|
+
// --- Tool Calling ---
|
|
65
55
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
66
|
-
const
|
|
56
|
+
const agentId = CONFIG.instanceId;
|
|
67
57
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
// Special handling for switch_project
|
|
59
|
+
if (request.params.name === "switch_project") {
|
|
60
|
+
const args = request.params.arguments;
|
|
61
|
+
if (args.project_id) {
|
|
62
|
+
const manifest = await StorageManager.getProjectManifest(args.project_id);
|
|
63
|
+
if (manifest) {
|
|
64
|
+
this.currentProject = args.project_id;
|
|
65
|
+
return { content: [{ type: "text", text: `Switched to project: ${args.project_id}` }] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { content: [{ type: "text", text: `Project '${args.project_id}' not found.` }] };
|
|
69
|
+
}
|
|
70
|
+
// Host permission check for privileged tools
|
|
71
|
+
const hostOnlyTools = ["delete_project", "rename_project", "clear_global_logs", "archive_meeting"];
|
|
72
|
+
if (hostOnlyTools.includes(request.params.name)) {
|
|
73
|
+
try {
|
|
74
|
+
checkHostPermission(request.params.name);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { content: [{ type: "text", text: `[Permission Denied] Tool '${request.params.name}' requires Host privileges.` }] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Delegate to tool handler
|
|
81
|
+
const ctx = {
|
|
71
82
|
currentProject: this.currentProject,
|
|
72
83
|
setCurrentProject: (id) => { this.currentProject = id; },
|
|
73
|
-
notifyResourceUpdate: (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
84
|
+
notifyResourceUpdate: (_uri) => { }
|
|
85
|
+
};
|
|
86
|
+
const result = await handleToolCall(request.params.name, request.params.arguments, ctx);
|
|
77
87
|
return result;
|
|
78
88
|
}
|
|
79
89
|
catch (error) {
|
|
80
|
-
|
|
81
|
-
throw error;
|
|
82
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
90
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
83
91
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
content: [{ type: "text", text: sanitizeErrorMessage(`Tool Error: ${msg}`) }],
|
|
93
|
+
isError: true
|
|
86
94
|
};
|
|
87
95
|
}
|
|
88
96
|
});
|
|
89
97
|
// --- Prompt Listing ---
|
|
90
|
-
this.server.setRequestHandler(ListPromptsRequestSchema, async () =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}));
|
|
102
|
-
// --- Prompt Retrieval ---
|
|
98
|
+
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
99
|
+
return {
|
|
100
|
+
prompts: [
|
|
101
|
+
{
|
|
102
|
+
name: "nexus_status",
|
|
103
|
+
description: "Get a comprehensive status report of the current Nexus Hub state"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
// --- Prompt Getting ---
|
|
103
109
|
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const projectId = `${projectType}_${technicalName}`;
|
|
110
|
+
if (request.params.name === "nexus_status") {
|
|
111
|
+
const registry = await StorageManager.listRegistry();
|
|
112
|
+
const projectCount = Object.keys(registry.projects).length;
|
|
113
|
+
const logs = await StorageManager.getRecentLogs(5);
|
|
109
114
|
return {
|
|
110
|
-
|
|
111
|
-
messages: [
|
|
112
|
-
{
|
|
115
|
+
messages: [{
|
|
113
116
|
role: "user",
|
|
114
117
|
content: {
|
|
115
118
|
type: "text",
|
|
116
|
-
text: `
|
|
119
|
+
text: `Nexus Hub Status:
|
|
120
|
+
- Role: ${CONFIG.isHost ? "Host" : "Guest"}
|
|
121
|
+
- Instance: ${CONFIG.instanceId}
|
|
122
|
+
- Port: ${CONFIG.port}
|
|
123
|
+
- Active Projects: ${projectCount}
|
|
124
|
+
- Recent Activity: ${logs.length} entries`
|
|
117
125
|
}
|
|
118
|
-
}
|
|
119
|
-
{
|
|
120
|
-
role: "assistant",
|
|
121
|
-
content: {
|
|
122
|
-
type: "text",
|
|
123
|
-
text: `## Project ID Convention\n\nBased on your input, the correct Project ID is:\n\n\`\`\`\n${projectId}\n\`\`\`\n\n### Prefix Dictionary\n| Prefix | Use Case |\n|--------|----------|\n| web_ | Websites/Domains |\n| api_ | Backend Services |\n| chrome_ | Chrome Extensions |\n| vscode_ | VSCode Extensions |\n| mcp_ | MCP Servers |\n| android_ | Native Android |\n| ios_ | Native iOS |\n| flutter_ | Cross-platform Mobile |\n| desktop_ | Desktop Apps |\n| lib_ | Libraries/SDKs |\n| bot_ | Bots |\n| infra_ | Infrastructure as Code |\n| doc_ | Technical Docs |\n\n### Next Steps\n1. Call \`register_session_context\` with projectId: \`${projectId}\`\n2. Call \`sync_project_assets\` with your manifest and internal docs.`
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
]
|
|
126
|
+
}]
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
-
throw new McpError(ErrorCode.InvalidRequest, `
|
|
129
|
+
throw new McpError(ErrorCode.InvalidRequest, `Prompt not found: ${request.params.name}`);
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
// Handle graceful shutdown
|
|
132
|
+
setupShutdownHandlers() {
|
|
134
133
|
const shutdown = async (signal) => {
|
|
135
134
|
console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
|
|
136
135
|
try {
|
|
@@ -141,154 +140,28 @@ class NexusServer {
|
|
|
141
140
|
catch { /* ignore */ }
|
|
142
141
|
process.exit(0);
|
|
143
142
|
};
|
|
144
|
-
// Global Error Handlers to prevent process exit on background errors
|
|
145
143
|
process.on("uncaughtException", (err) => {
|
|
146
144
|
console.error("[Nexus CRITICAL] Uncaught Exception:", err);
|
|
147
|
-
// Attempt to log to disk if possible, but keep process alive if safe
|
|
148
|
-
// For a Hub, staying alive is often preferred over crashing
|
|
149
145
|
});
|
|
150
146
|
process.on("unhandledRejection", (reason, promise) => {
|
|
151
147
|
console.error("[Nexus WARNING] Unhandled Rejection at:", promise, "reason:", reason);
|
|
152
|
-
// Do not exit. Background tasks (like file sync) often trigger this.
|
|
153
148
|
});
|
|
154
149
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
155
150
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
151
|
+
}
|
|
152
|
+
async run() {
|
|
153
|
+
this.setupShutdownHandlers();
|
|
154
|
+
const context = {
|
|
155
|
+
config: CONFIG,
|
|
156
|
+
pkg,
|
|
157
|
+
mcpServer: this.server,
|
|
158
|
+
sseTransports: this.sseTransports
|
|
159
|
+
};
|
|
156
160
|
if (CONFIG.isHost && hostServer) {
|
|
157
|
-
|
|
158
|
-
await StorageManager.init();
|
|
159
|
-
hostServer.on("request", async (req, res) => {
|
|
160
|
-
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
161
|
-
if (url.pathname === "/mcp") {
|
|
162
|
-
const guestId = url.searchParams.get("id") || "UnknownGuest";
|
|
163
|
-
if (req.method === "GET") {
|
|
164
|
-
const transport = new SSEServerTransport("/mcp", res);
|
|
165
|
-
this.sseTransports.set(transport.sessionId, transport);
|
|
166
|
-
const msg = `Guest Joined: ${guestId}`;
|
|
167
|
-
await StorageManager.addGlobalLog(`HOST:${CONFIG.instanceId}`, msg, "UPDATE");
|
|
168
|
-
console.error(`[Nexus Hub] ${msg} (Session: ${transport.sessionId})`);
|
|
169
|
-
// Heartbeat: keep connection alive
|
|
170
|
-
const heartbeat = setInterval(() => {
|
|
171
|
-
try {
|
|
172
|
-
res.write(": ping\n\n");
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
clearInterval(heartbeat);
|
|
176
|
-
}
|
|
177
|
-
}, 30000);
|
|
178
|
-
transport.onclose = () => {
|
|
179
|
-
this.sseTransports.delete(transport.sessionId);
|
|
180
|
-
clearInterval(heartbeat);
|
|
181
|
-
console.error(`[Nexus Hub] Guest Left: ${guestId}`);
|
|
182
|
-
};
|
|
183
|
-
await this.server.connect(transport);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
else if (req.method === "POST") {
|
|
187
|
-
const sessionId = url.searchParams.get("sessionId");
|
|
188
|
-
const transport = sessionId ? this.sseTransports.get(sessionId) : null;
|
|
189
|
-
if (transport) {
|
|
190
|
-
await transport.handlePostMessage(req, res);
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
res.writeHead(404).end("Session unknown");
|
|
194
|
-
}
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
// Support local stdio for the host's own IDE
|
|
200
|
-
const transport = new StdioServerTransport();
|
|
201
|
-
await this.server.connect(transport);
|
|
202
|
-
const onlineMsg = `Nexus Hub Active. Playing Host.`;
|
|
203
|
-
await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, onlineMsg, "UPDATE");
|
|
204
|
-
console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg} (Port: ${CONFIG.port})`);
|
|
161
|
+
await startHost(hostServer, context);
|
|
205
162
|
}
|
|
206
163
|
else {
|
|
207
|
-
|
|
208
|
-
const guestId = CONFIG.instanceId;
|
|
209
|
-
let retryCount = 0;
|
|
210
|
-
const maxRetries = 50; // Prevent infinite reconnection loops
|
|
211
|
-
// Random delay function to prevent thundering herd during re-election
|
|
212
|
-
const randomDelay = () => Math.floor(Math.random() * 3000);
|
|
213
|
-
const startProxy = () => {
|
|
214
|
-
if (retryCount >= maxRetries) {
|
|
215
|
-
console.error(`[Nexus Guest] Max retries (${maxRetries}) reached. Exiting.`);
|
|
216
|
-
process.exit(1);
|
|
217
|
-
}
|
|
218
|
-
retryCount++;
|
|
219
|
-
// Clear any stale stdin listeners before starting
|
|
220
|
-
process.stdin.removeAllListeners("data");
|
|
221
|
-
console.error(`[Nexus:${guestId}] Global Hub detected at ${CONFIG.port}. Joining... (attempt ${retryCount})`);
|
|
222
|
-
let sessionId = null;
|
|
223
|
-
let lastActivity = Date.now();
|
|
224
|
-
// Watchdog: trigger re-election if Host is silent for too long
|
|
225
|
-
const watchdog = setInterval(() => {
|
|
226
|
-
if (Date.now() - lastActivity > 60000) {
|
|
227
|
-
console.error("[Nexus Guest] Host stale. Reconnecting...");
|
|
228
|
-
cleanup();
|
|
229
|
-
// Use setImmediate to break call stack, then delay
|
|
230
|
-
setImmediate(() => setTimeout(startProxy, randomDelay()));
|
|
231
|
-
}
|
|
232
|
-
}, 10000);
|
|
233
|
-
const cleanup = () => {
|
|
234
|
-
clearInterval(watchdog);
|
|
235
|
-
process.stdin.removeAllListeners("data");
|
|
236
|
-
};
|
|
237
|
-
const stdioHandler = (chunk) => {
|
|
238
|
-
if (!sessionId)
|
|
239
|
-
return;
|
|
240
|
-
try {
|
|
241
|
-
const req = http.request({
|
|
242
|
-
hostname: "127.0.0.1",
|
|
243
|
-
port: CONFIG.port,
|
|
244
|
-
path: `/mcp?sessionId=${sessionId}&id=${encodeURIComponent(guestId)}`,
|
|
245
|
-
method: "POST",
|
|
246
|
-
headers: { "Content-Type": "application/json" }
|
|
247
|
-
});
|
|
248
|
-
// Handle request errors to prevent unhandled exceptions
|
|
249
|
-
req.on("error", () => { });
|
|
250
|
-
req.write(chunk);
|
|
251
|
-
req.end();
|
|
252
|
-
}
|
|
253
|
-
catch { /* suppress */ }
|
|
254
|
-
};
|
|
255
|
-
process.stdin.on("data", stdioHandler);
|
|
256
|
-
http.get(`http://127.0.0.1:${CONFIG.port}/mcp?id=${encodeURIComponent(guestId)}`, (res) => {
|
|
257
|
-
retryCount = 0; // Reset on successful connection
|
|
258
|
-
let buffer = "";
|
|
259
|
-
res.on("data", (chunk) => {
|
|
260
|
-
lastActivity = Date.now();
|
|
261
|
-
const str = chunk.toString();
|
|
262
|
-
buffer += str;
|
|
263
|
-
if (!sessionId && buffer.includes("event: endpoint")) {
|
|
264
|
-
const match = buffer.match(/sessionId=([a-f0-9-]+)/);
|
|
265
|
-
if (match)
|
|
266
|
-
sessionId = match[1];
|
|
267
|
-
}
|
|
268
|
-
if (str.includes("event: message")) {
|
|
269
|
-
const lines = str.split("\n");
|
|
270
|
-
const dataLine = lines.find((l) => l.startsWith("data: "));
|
|
271
|
-
if (dataLine) {
|
|
272
|
-
try {
|
|
273
|
-
process.stdout.write(dataLine.substring(6) + "\n");
|
|
274
|
-
}
|
|
275
|
-
catch { /* ignore stdout errors */ }
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
res.on("end", () => {
|
|
280
|
-
console.error("[Nexus Guest] Lost connection to Host. Reconnecting...");
|
|
281
|
-
cleanup();
|
|
282
|
-
// Use setImmediate to break call stack
|
|
283
|
-
setImmediate(() => setTimeout(startProxy, randomDelay()));
|
|
284
|
-
});
|
|
285
|
-
}).on("error", () => {
|
|
286
|
-
console.error("[Nexus Guest] Proxy Receive Error. Retrying...");
|
|
287
|
-
cleanup();
|
|
288
|
-
setImmediate(() => setTimeout(startProxy, 1000 + randomDelay()));
|
|
289
|
-
});
|
|
290
|
-
};
|
|
291
|
-
startProxy();
|
|
164
|
+
await startGuest(CONFIG.port, context);
|
|
292
165
|
}
|
|
293
166
|
}
|
|
294
167
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Election Logic
|
|
3
|
+
*
|
|
4
|
+
* Handles port scanning, handshake probing, and Host/Guest election.
|
|
5
|
+
*/
|
|
6
|
+
import http from "http";
|
|
7
|
+
import { NEXUS_HOST, PORT_RANGE_START, PORT_RANGE_END, HANDSHAKE_TIMEOUT, SERVICE_NAME } from "../constants.js";
|
|
8
|
+
import { getArg } from "../config/cli.js";
|
|
9
|
+
// We need pkg version for handshake - import dynamically to avoid circular dep
|
|
10
|
+
let pkgVersion = "0.0.0";
|
|
11
|
+
import("../config/index.js").then(m => { pkgVersion = m.pkg.version; }).catch(() => { });
|
|
12
|
+
/**
|
|
13
|
+
* Probe a port to see if it's a Nexus Host using the Custom Handshake Protocol
|
|
14
|
+
*/
|
|
15
|
+
export async function probeHost(port, myId) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const postData = JSON.stringify({
|
|
18
|
+
clientVersion: pkgVersion,
|
|
19
|
+
instanceId: myId
|
|
20
|
+
});
|
|
21
|
+
const req = http.request({
|
|
22
|
+
hostname: NEXUS_HOST,
|
|
23
|
+
port: port,
|
|
24
|
+
path: "/nexus/handshake",
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"Content-Length": Buffer.byteLength(postData)
|
|
29
|
+
},
|
|
30
|
+
timeout: HANDSHAKE_TIMEOUT
|
|
31
|
+
}, (res) => {
|
|
32
|
+
let data = "";
|
|
33
|
+
res.on("data", (chunk) => data += chunk);
|
|
34
|
+
res.on("end", () => {
|
|
35
|
+
try {
|
|
36
|
+
const info = JSON.parse(data);
|
|
37
|
+
if (info.service === SERVICE_NAME && info.role === "host") {
|
|
38
|
+
resolve({ isNexus: true, rootStorage: info.rootStorage });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
resolve({ isNexus: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve({ isNexus: false });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on("error", () => resolve({ isNexus: false }));
|
|
50
|
+
req.on("timeout", () => {
|
|
51
|
+
req.destroy();
|
|
52
|
+
resolve({ isNexus: false });
|
|
53
|
+
});
|
|
54
|
+
req.write(postData);
|
|
55
|
+
req.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Automatic Host Election
|
|
60
|
+
*
|
|
61
|
+
* For each port in range:
|
|
62
|
+
* 1. Try to bind → Success → I am Host
|
|
63
|
+
* 2. Bind fails → Try handshake → Success → I am Guest
|
|
64
|
+
* 3. Handshake fails → Port occupied by non-Nexus → Next port
|
|
65
|
+
*/
|
|
66
|
+
export async function isHostAutoElection(_root, blacklistPorts = []) {
|
|
67
|
+
const startPort = PORT_RANGE_START;
|
|
68
|
+
const endPort = PORT_RANGE_END;
|
|
69
|
+
const myId = getArg("--id") || `node-${Math.random().toString(36).substring(2, 6)}`;
|
|
70
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
71
|
+
if (blacklistPorts.includes(port))
|
|
72
|
+
continue;
|
|
73
|
+
// 1. Try to bind port
|
|
74
|
+
const bindResult = await new Promise((resolve) => {
|
|
75
|
+
const server = http.createServer();
|
|
76
|
+
server.on("error", () => resolve({ success: false }));
|
|
77
|
+
server.listen(port, NEXUS_HOST, () => resolve({ success: true, server }));
|
|
78
|
+
});
|
|
79
|
+
if (bindResult.success) {
|
|
80
|
+
// Bind success → I am Host
|
|
81
|
+
return { isHost: true, port, server: bindResult.server };
|
|
82
|
+
}
|
|
83
|
+
// 2. Bind failed → Try handshake
|
|
84
|
+
const probe = await probeHost(port, myId);
|
|
85
|
+
if (probe.isNexus) {
|
|
86
|
+
// Handshake success → I am Guest
|
|
87
|
+
return { isHost: false, port, rootStorage: probe.rootStorage };
|
|
88
|
+
}
|
|
89
|
+
// 3. Handshake failed → Port occupied by non-Nexus → Continue
|
|
90
|
+
}
|
|
91
|
+
// All ports unavailable
|
|
92
|
+
console.error(`[Nexus] All ports ${startPort}-${endPort} occupied by non-Nexus processes.`);
|
|
93
|
+
throw new Error("No available port for Nexus");
|
|
94
|
+
}
|