n2n-nexus 0.8.2 → 0.9.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/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +49 -0
- package/README.md +157 -77
- package/SECURITY.md +37 -0
- package/build/cli/client-command.js +58 -0
- package/build/cli/doctor.js +58 -8
- package/build/cli/entry.js +5 -0
- package/build/cli/hook.js +104 -16
- package/build/cli/install.js +37 -13
- package/build/cli/mcp-config.js +16 -12
- package/build/client/discovery.js +115 -0
- package/build/client/nexus-client.js +8 -2
- package/build/contract.js +69 -0
- package/build/index.js +12 -18
- package/build/server/nexus.js +63 -35
- package/contract/errors.json +52 -0
- package/contract/hook.json +156 -0
- package/contract/tools.json +2812 -0
- package/docs/ARCHITECTURE.md +142 -108
- package/docs/ARCHITECTURE_zh.md +144 -110
- package/docs/ASSISTANT_GUIDE.md +5 -4
- package/docs/ASSISTANT_GUIDE_zh.md +134 -0
- package/docs/MEETING_MINUTES_2025-12-29.md +166 -0
- package/docs/README_zh.md +159 -79
- package/docs/TOOLS_REFERENCE.md +404 -0
- package/docs/discussion_2025-12-29.md +727 -0
- package/llms.txt +16 -5
- package/package.json +12 -8
- package/build/cli/autostart.js +0 -64
- package/build/config/cli.js +0 -8
- package/build/config/paths.js +0 -38
- package/build/constants.js +0 -18
- package/build/daemon/hook-contract.js +0 -68
- package/build/daemon/index.js +0 -57
- package/build/daemon/rate-limit.js +0 -62
- package/build/daemon/server.js +0 -862
- package/build/daemon/tool-outputs.js +0 -180
- package/build/daemon/tools.js +0 -326
- package/build/storage/agents.js +0 -227
- package/build/storage/docs.js +0 -81
- package/build/storage/events.js +0 -31
- package/build/storage/fs-utils.js +0 -88
- package/build/storage/global-messages.js +0 -44
- package/build/storage/index.js +0 -141
- package/build/storage/migrations.js +0 -87
- package/build/storage/paths.js +0 -23
- package/build/storage/projects.js +0 -53
- package/build/storage/registry.js +0 -355
- package/build/storage/sqlite-meeting.js +0 -323
- package/build/storage/sqlite.js +0 -275
- package/build/storage/tasks.js +0 -138
- package/build/types.js +0 -1
- package/build/utils/async-rwlock.js +0 -64
package/build/cli/doctor.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `n2n-nexus doctor [claude|codex]`: check that the Node path and the entry
|
|
3
3
|
* script in the hook commands and in the MCP server command that `install`
|
|
4
|
-
* wrote still exist and run,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* wrote still exist and run, that the host those commands talk to answers
|
|
5
|
+
* GET /health as n2n-nexus-host with this client's contract version, and say
|
|
6
|
+
* how to fix what does not. Without a client both are checked; a client with
|
|
7
|
+
* nothing installed is only reported when it was named. Exit code 1 when a
|
|
8
|
+
* check failed.
|
|
7
9
|
*/
|
|
8
10
|
import { spawnSync } from "node:child_process";
|
|
9
11
|
import fs from "node:fs";
|
|
10
|
-
import { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../
|
|
12
|
+
import { CONTRACT_VERSION, HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient, noDaemonHint } from "../contract.js";
|
|
13
|
+
import { NexusClient, NexusToolError } from "../client/nexus-client.js";
|
|
14
|
+
import { LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
|
|
11
15
|
import { pkg } from "../config/index.js";
|
|
12
16
|
import { VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
|
|
13
17
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
@@ -38,8 +42,46 @@ export function checkNode(node) {
|
|
|
38
42
|
return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
|
|
39
43
|
}
|
|
40
44
|
const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
|
|
41
|
-
/**
|
|
42
|
-
|
|
45
|
+
/** The `--endpoint` a hook command was installed with, if any. */
|
|
46
|
+
const hookEndpoint = (command) => / --endpoint (\S+)/.exec(command)?.[1];
|
|
47
|
+
/** Ask the host at `endpoint` for GET /health: it must be n2n-nexus-host speaking this client's contract version. */
|
|
48
|
+
export async function checkHost(endpoint) {
|
|
49
|
+
let health;
|
|
50
|
+
try {
|
|
51
|
+
health = await new NexusClient({ endpoint, timeoutMs: 3000 }).health();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// It answered but refused (e.g. 403 for a Host name not in its allowedHosts): running, not missing.
|
|
55
|
+
if (error instanceof NexusToolError)
|
|
56
|
+
return bad(`answers with HTTP ${error.status}: ${error.message}`);
|
|
57
|
+
return bad(`not reachable (${error.message}). ${noDaemonHint(endpoint)}`);
|
|
58
|
+
}
|
|
59
|
+
if (health.service !== "n2n-nexus")
|
|
60
|
+
return bad("answers, but is not an n2n-nexus host");
|
|
61
|
+
const version = typeof health.version === "string" ? health.version : "unknown version";
|
|
62
|
+
if (health.contractVersion !== CONTRACT_VERSION) {
|
|
63
|
+
return bad(`n2n-nexus-host ${version} speaks contract version ${String(health.contractVersion)}, this client ${CONTRACT_VERSION}: upgrade the older of n2n-nexus-host and n2n-nexus`);
|
|
64
|
+
}
|
|
65
|
+
return ok(`n2n-nexus-host ${version}, contract version ${CONTRACT_VERSION}`);
|
|
66
|
+
}
|
|
67
|
+
/** Check the host at each endpoint; prints the report and returns whether all passed. */
|
|
68
|
+
async function doctorHosts(endpoints) {
|
|
69
|
+
let passed = true;
|
|
70
|
+
for (const configured of endpoints) {
|
|
71
|
+
const endpoint = await resolveEndpoint(configured);
|
|
72
|
+
const check = await checkHost(endpoint);
|
|
73
|
+
if (!check.ok)
|
|
74
|
+
passed = false;
|
|
75
|
+
const label = endpoint === configured ? endpoint : `${endpoint} (${configured})`;
|
|
76
|
+
process.stdout.write(`host ${label}\n ${check.ok ? "ok " : "FAIL"} ${check.text}\n`);
|
|
77
|
+
}
|
|
78
|
+
return passed;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed.
|
|
82
|
+
* Adds the endpoints its hook commands use (`defaultEndpoint` for those without --endpoint) to `endpoints`.
|
|
83
|
+
*/
|
|
84
|
+
function doctorClient(client, endpoints, defaultEndpoint) {
|
|
43
85
|
const hooksFile = configFile(client);
|
|
44
86
|
const mcpFile = mcpConfigFile(client);
|
|
45
87
|
const lines = [];
|
|
@@ -70,6 +112,8 @@ function doctorClient(client) {
|
|
|
70
112
|
const mcp = readMcpCommand(client, mcpFile);
|
|
71
113
|
if (commands.size === 0 && !mcp)
|
|
72
114
|
return undefined;
|
|
115
|
+
for (const command of commands)
|
|
116
|
+
endpoints.add(hookEndpoint(command) ?? defaultEndpoint);
|
|
73
117
|
lines.push(`${client}`);
|
|
74
118
|
if (commands.size === 0)
|
|
75
119
|
report(`hooks in ${hooksFile}`, bad("not installed"));
|
|
@@ -103,7 +147,7 @@ function doctorClient(client) {
|
|
|
103
147
|
return !failed;
|
|
104
148
|
}
|
|
105
149
|
/** Parse `[claude|codex]`, run the checks and return the exit code. */
|
|
106
|
-
export function runDoctor(argv) {
|
|
150
|
+
export async function runDoctor(argv) {
|
|
107
151
|
const client = argv[0];
|
|
108
152
|
if (client !== undefined && !isHookClient(client)) {
|
|
109
153
|
process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
|
|
@@ -111,8 +155,10 @@ export function runDoctor(argv) {
|
|
|
111
155
|
}
|
|
112
156
|
try {
|
|
113
157
|
let failed = false;
|
|
158
|
+
const defaultEndpoint = process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
|
|
159
|
+
const endpoints = new Set();
|
|
114
160
|
for (const c of client ? [client] : HOOK_CLIENTS) {
|
|
115
|
-
const result = doctorClient(c);
|
|
161
|
+
const result = doctorClient(c, endpoints, defaultEndpoint);
|
|
116
162
|
if (result === undefined) {
|
|
117
163
|
if (client) {
|
|
118
164
|
process.stdout.write(`${c}\n FAIL nothing installed: run "n2n-nexus install ${c}".\n`);
|
|
@@ -126,6 +172,10 @@ export function runDoctor(argv) {
|
|
|
126
172
|
failed = true;
|
|
127
173
|
}
|
|
128
174
|
}
|
|
175
|
+
if (endpoints.size === 0)
|
|
176
|
+
endpoints.add(defaultEndpoint);
|
|
177
|
+
if (!await doctorHosts(endpoints))
|
|
178
|
+
failed = true;
|
|
129
179
|
return failed ? 1 : 0;
|
|
130
180
|
}
|
|
131
181
|
catch (error) {
|
package/build/cli/hook.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n2n-nexus hook <claude|codex> [--endpoint <url>]`: forward one client
|
|
3
|
-
* event to the daemon and print its output
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* `n2n-nexus hook <claude|codex> [--endpoint <url>] [--wait]`: forward one client
|
|
3
|
+
* hook event to the daemon and print its output, or with `--wait` run the
|
|
4
|
+
* background wait that wakes an idle conversation. Runs on every tool call, so
|
|
5
|
+
* it loads only node built-ins (no MCP SDK, zod or SQLite), and every failure
|
|
6
|
+
* is silent: exit 0 with nothing on stdout, so the client carries on unaffected.
|
|
6
7
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { BACKGROUND_WAIT_EVENT, HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, WAIT_HOOK_TIMEOUT_SEC, noDaemonHint, isHookClient } from "../contract.js";
|
|
10
|
+
import { NexusClient, NexusTransportError, REQUEST_TIMEOUT_CODE } from "../client/nexus-client.js";
|
|
11
|
+
import { DISCOVER_ENDPOINT, LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
|
|
12
|
+
/** Errors after which a discovered daemon is looked up again: it was never reached, so resending is safe. */
|
|
13
|
+
const UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT", "EADDRNOTAVAIL"]);
|
|
10
14
|
function isPlainObject(value) {
|
|
11
15
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
16
|
}
|
|
@@ -25,7 +29,7 @@ async function forward(argv) {
|
|
|
25
29
|
const client = argv[0];
|
|
26
30
|
if (!isHookClient(client))
|
|
27
31
|
throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
|
|
28
|
-
const
|
|
32
|
+
const configured = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
|
|
29
33
|
const stdin = JSON.parse(await readStdin());
|
|
30
34
|
if (!isPlainObject(stdin))
|
|
31
35
|
throw new Error("hook input is not a JSON object");
|
|
@@ -35,19 +39,33 @@ async function forward(argv) {
|
|
|
35
39
|
input[field] = stdin[field];
|
|
36
40
|
}
|
|
37
41
|
const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
|
|
38
|
-
|
|
39
|
-
const send = () =>
|
|
42
|
+
/** Send to `endpoint`; nothing listening there is reported with what to do about it. */
|
|
43
|
+
const send = async (endpoint) => {
|
|
44
|
+
try {
|
|
45
|
+
return await new NexusClient({ endpoint }).hook({ client, input }, timeoutMs);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (!(error instanceof NexusTransportError && error.code === "ECONNREFUSED"))
|
|
49
|
+
throw error;
|
|
50
|
+
throw new NexusTransportError(`No daemon at ${endpoint}. ${noDaemonHint(endpoint)}`, error.code);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const endpoint = await resolveEndpoint(configured);
|
|
40
54
|
let response;
|
|
41
55
|
try {
|
|
42
|
-
response = await send();
|
|
56
|
+
response = await send(endpoint);
|
|
43
57
|
}
|
|
44
58
|
catch (error) {
|
|
45
|
-
|
|
59
|
+
// The cached daemon is gone: look again, and fall back to the local endpoint if none answers.
|
|
60
|
+
// A timed-out Stop may have been waiting for messages on purpose, so only other events count a timeout.
|
|
61
|
+
const code = error instanceof NexusTransportError ? error.code ?? "" : "";
|
|
62
|
+
const unreachable = UNREACHABLE_CODES.has(code) || (code === REQUEST_TIMEOUT_CODE && stdin.hook_event_name !== "Stop");
|
|
63
|
+
if (configured !== DISCOVER_ENDPOINT || !unreachable)
|
|
46
64
|
throw error;
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
65
|
+
const found = await resolveEndpoint(configured, { fresh: true });
|
|
66
|
+
if (found === endpoint)
|
|
49
67
|
throw error;
|
|
50
|
-
response = await send();
|
|
68
|
+
response = await send(found);
|
|
51
69
|
}
|
|
52
70
|
if (!isPlainObject(response) || !("output" in response))
|
|
53
71
|
throw new Error("invalid response from daemon");
|
|
@@ -58,8 +76,78 @@ async function forward(argv) {
|
|
|
58
76
|
throw new Error("invalid output from daemon");
|
|
59
77
|
return JSON.stringify(output) + "\n";
|
|
60
78
|
}
|
|
61
|
-
/**
|
|
79
|
+
/** The background wait ends before the client's hook timeout would kill it during a request. */
|
|
80
|
+
const WAIT_DEADLINE_MS = (WAIT_HOOK_TIMEOUT_SEC - 10) * 1000;
|
|
81
|
+
/**
|
|
82
|
+
* Ask the daemon until something arrives for the session (the notification
|
|
83
|
+
* text), the session becomes active again or the deadline passes (null).
|
|
84
|
+
*/
|
|
85
|
+
export async function waitForWake(client, sessionId, endpoint, deadlineMs = WAIT_DEADLINE_MS) {
|
|
86
|
+
const nexus = new NexusClient({ endpoint });
|
|
87
|
+
// Start a request only if it can run to its own timeout before the deadline.
|
|
88
|
+
const deadline = Date.now() + deadlineMs - HOOK_STOP_REQUEST_TIMEOUT_MS;
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
const response = await nexus.hook({ client, input: { hook_event_name: BACKGROUND_WAIT_EVENT, session_id: sessionId } }, HOOK_STOP_REQUEST_TIMEOUT_MS);
|
|
91
|
+
const output = isPlainObject(response) ? response.output : null;
|
|
92
|
+
if (isPlainObject(output) && typeof output.wake === "string")
|
|
93
|
+
return output.wake;
|
|
94
|
+
if (!(isPlainObject(output) && output.timeout === true))
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `--wait`, started by a Stop hook when a turn ends. Claude runs it as an
|
|
101
|
+
* asyncRewake hook: exit code 2 with the notification on stderr wakes the
|
|
102
|
+
* conversation. Codex has no such hook, so its Stop hook starts a detached copy
|
|
103
|
+
* (`--session <id>`) and returns at once; the copy hands the notification to
|
|
104
|
+
* `codex queue` (NEXUS_CODEX_COMMAND, default `codex`), which starts a turn.
|
|
105
|
+
*/
|
|
106
|
+
async function runWait(argv) {
|
|
107
|
+
const client = argv[0];
|
|
108
|
+
if (!isHookClient(client))
|
|
109
|
+
throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
|
|
110
|
+
// claude -p (unattended) waits for this hook before it exits, and nobody would see a woken turn.
|
|
111
|
+
if (client === "claude" && process.env.CLAUDE_CODE_SESSION_ATTENDED === "0")
|
|
112
|
+
return 0;
|
|
113
|
+
const configured = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
|
|
114
|
+
let sessionId = argValue(argv, "--session");
|
|
115
|
+
if (!sessionId) {
|
|
116
|
+
const stdin = JSON.parse(await readStdin());
|
|
117
|
+
if (!isPlainObject(stdin) || typeof stdin.session_id !== "string" || !stdin.session_id)
|
|
118
|
+
return 0;
|
|
119
|
+
sessionId = stdin.session_id;
|
|
120
|
+
if (client === "codex") {
|
|
121
|
+
const { entryScript } = await import("./entry.js");
|
|
122
|
+
const args = [entryScript(), "hook", "codex", "--wait", "--session", sessionId, ...(argValue(argv, "--endpoint") ? ["--endpoint", configured] : [])];
|
|
123
|
+
spawn(process.execPath, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const text = await waitForWake(client, sessionId, await resolveEndpoint(configured));
|
|
128
|
+
if (text === null)
|
|
129
|
+
return 0;
|
|
130
|
+
if (client === "codex") {
|
|
131
|
+
// Never through cmd.exe: the text comes from another session and spans several lines.
|
|
132
|
+
const { spawnClientSync } = await import("./client-command.js");
|
|
133
|
+
spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", text], { stdio: "ignore", windowsHide: true });
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
await new Promise((resolve) => process.stderr.write(text + "\n", () => resolve()));
|
|
137
|
+
return 2;
|
|
138
|
+
}
|
|
139
|
+
/** Run the hook command; resolves to exit code 0, or 2 when a background wait wakes a Claude conversation. */
|
|
62
140
|
export async function runHook(argv) {
|
|
141
|
+
if (argv.includes("--wait")) {
|
|
142
|
+
try {
|
|
143
|
+
return await runWait(argv);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
process.stderr.write(`[n2n-nexus] hook --wait: ${message.replace(/\s+/g, " ").slice(0, 300)}\n`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
63
151
|
let text = "";
|
|
64
152
|
try {
|
|
65
153
|
text = await forward(argv);
|
package/build/cli/install.js
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* is the running binary, because process.execPath is the resolved, versioned
|
|
11
11
|
* location (nvm, Homebrew Cellar, ...) that disappears when Node is upgraded.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
|
-
import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, isHookClient } from "../
|
|
17
|
+
import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient } from "../contract.js";
|
|
18
18
|
import { pkg } from "../config/index.js";
|
|
19
|
-
import {
|
|
19
|
+
import { spawnClientSync } from "./client-command.js";
|
|
20
|
+
import { entryScript } from "./entry.js";
|
|
20
21
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
21
22
|
const EVENTS = {
|
|
22
23
|
claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
|
|
@@ -25,9 +26,9 @@ const EVENTS = {
|
|
|
25
26
|
export class InstallError extends Error {
|
|
26
27
|
}
|
|
27
28
|
/** Path segments of Node installs that are replaced on upgrade. */
|
|
28
|
-
export const VERSIONED_NODE_DIR =
|
|
29
|
+
export const VERSIONED_NODE_DIR = /[/\\]Cellar[/\\]|[/\\]\.nvm[/\\]versions[/\\]|[/\\]node-v\d|[/\\]fnm[/\\]|[/\\]fnm_multishells[/\\]|[/\\]\.volta[/\\]|[/\\]\.asdf[/\\]installs[/\\]/;
|
|
29
30
|
/** fnm puts a per-shell symlink directory first on PATH; it is gone when the shell ends. */
|
|
30
|
-
const PER_SHELL_NODE_DIR =
|
|
31
|
+
const PER_SHELL_NODE_DIR = /[/\\]fnm_multishells[/\\]/;
|
|
31
32
|
function sameFile(a, b) {
|
|
32
33
|
try {
|
|
33
34
|
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
@@ -45,7 +46,7 @@ export function stableNodePath(env = process.env) {
|
|
|
45
46
|
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
46
47
|
if (!path.isAbsolute(dir) || PER_SHELL_NODE_DIR.test(dir))
|
|
47
48
|
continue;
|
|
48
|
-
const candidate = path.join(dir, "node");
|
|
49
|
+
const candidate = path.join(dir, process.platform === "win32" ? "node.exe" : "node");
|
|
49
50
|
if (sameFile(candidate, process.execPath))
|
|
50
51
|
return candidate;
|
|
51
52
|
}
|
|
@@ -70,7 +71,7 @@ export function satisfiesEngines(version, range = pkg.engines.node) {
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
/** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
|
|
73
|
-
function checkNodePath(given) {
|
|
74
|
+
export function checkNodePath(given) {
|
|
74
75
|
const node = path.resolve(given);
|
|
75
76
|
try {
|
|
76
77
|
fs.accessSync(node, fs.constants.X_OK);
|
|
@@ -158,10 +159,12 @@ function removeOurEntries(config, client, file) {
|
|
|
158
159
|
}
|
|
159
160
|
return changed;
|
|
160
161
|
}
|
|
161
|
-
function addOurEntries(config, client, command) {
|
|
162
|
+
function addOurEntries(config, client, command, commandWindows) {
|
|
162
163
|
const hooks = (config.hooks ??= {});
|
|
163
164
|
for (const event of EVENTS[client]) {
|
|
164
165
|
const entry = { type: "command", command, timeout: event === "Stop" ? HOOK_STOP_TIMEOUT_SEC : HOOK_TIMEOUT_SEC };
|
|
166
|
+
if (commandWindows)
|
|
167
|
+
entry.commandWindows = commandWindows;
|
|
165
168
|
const group = client === "claude" && event === "PreToolUse"
|
|
166
169
|
? { matcher: CLAUDE_PRE_TOOL_MATCHER, hooks: [entry] }
|
|
167
170
|
: { hooks: [entry] };
|
|
@@ -171,6 +174,12 @@ function addOurEntries(config, client, command) {
|
|
|
171
174
|
else
|
|
172
175
|
hooks[event] = [group];
|
|
173
176
|
}
|
|
177
|
+
// The background wait that wakes an idle conversation (hook.ts --wait): Claude runs it as an
|
|
178
|
+
// asyncRewake hook; for Codex it starts a detached wait and returns at once.
|
|
179
|
+
const wait = client === "claude"
|
|
180
|
+
? { type: "command", command: `${command} --wait`, asyncRewake: true, timeout: WAIT_HOOK_TIMEOUT_SEC }
|
|
181
|
+
: { type: "command", command: `${command} --wait`, ...(commandWindows ? { commandWindows: `${commandWindows} '--wait'` } : {}), timeout: HOOK_TIMEOUT_SEC };
|
|
182
|
+
hooks.Stop.push({ hooks: [wait] });
|
|
174
183
|
}
|
|
175
184
|
function writeConfig(file, config) {
|
|
176
185
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -188,6 +197,18 @@ function mcpAddArgs(client, node, entry, endpoint) {
|
|
|
188
197
|
const scope = client === "claude" ? ["--scope", "user"] : [];
|
|
189
198
|
return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", node, entry, "mcp"];
|
|
190
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Run `<client> <args>` and throw like execFileSync when it fails. On Windows
|
|
202
|
+
* spawnClientSync runs the script behind npm's `.cmd` shim, never cmd.exe.
|
|
203
|
+
*/
|
|
204
|
+
function runClient(client, args, stdio) {
|
|
205
|
+
const result = spawnClientSync(client, args, { encoding: "utf8", stdio });
|
|
206
|
+
if (result.error)
|
|
207
|
+
throw result.error;
|
|
208
|
+
if (result.status !== 0) {
|
|
209
|
+
throw Object.assign(new Error(`Command failed: ${client} ${args.join(" ")}`), { status: result.status, stderr: result.stderr });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
191
212
|
/**
|
|
192
213
|
* Run `<client> mcp remove`. A failure is ignored only when `<client> mcp get`
|
|
193
214
|
* shows the server is not registered (never installed, or already removed);
|
|
@@ -196,7 +217,7 @@ function mcpAddArgs(client, node, entry, endpoint) {
|
|
|
196
217
|
function removeMcpServer(client, action, hooksRemoved) {
|
|
197
218
|
const removeArgs = mcpRemoveArgs(client);
|
|
198
219
|
try {
|
|
199
|
-
|
|
220
|
+
runClient(client, removeArgs, ["ignore", "ignore", "pipe"]);
|
|
200
221
|
if (action === "uninstall")
|
|
201
222
|
process.stdout.write(`Ran: ${client} ${removeArgs.join(" ")}\n`);
|
|
202
223
|
return;
|
|
@@ -227,10 +248,10 @@ export function runInstall(options) {
|
|
|
227
248
|
const { action, client, endpoint } = options;
|
|
228
249
|
const entry = options.entry ?? entryScript();
|
|
229
250
|
try {
|
|
230
|
-
if (action === "install" &&
|
|
251
|
+
if (action === "install" && /[/\\]_npx[/\\]/.test(entry)) {
|
|
231
252
|
throw new InstallError(`n2n-nexus is running from an npx cache (${entry}), which can be cleared at any time. Install it first: npm i -g n2n-nexus`);
|
|
232
253
|
}
|
|
233
|
-
const probe =
|
|
254
|
+
const probe = spawnClientSync(client, ["--version"], { stdio: "ignore" });
|
|
234
255
|
if (probe.error || probe.status !== 0) {
|
|
235
256
|
throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
|
|
236
257
|
}
|
|
@@ -240,7 +261,10 @@ export function runInstall(options) {
|
|
|
240
261
|
const removed = removeOurEntries(config, client, file);
|
|
241
262
|
if (action === "install") {
|
|
242
263
|
const command = `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
|
|
243
|
-
|
|
264
|
+
const commandWindows = client === "codex"
|
|
265
|
+
? `& ${[node, entry, "hook", client, ...(endpoint ? ["--endpoint", endpoint] : [])].map((arg) => `'${arg.replace(/['\u2018\u2019]/g, "$&$&")}'`).join(" ")}`
|
|
266
|
+
: undefined;
|
|
267
|
+
addOurEntries(config, client, command, commandWindows);
|
|
244
268
|
}
|
|
245
269
|
if (action === "install" || removed) {
|
|
246
270
|
writeConfig(file, config);
|
|
@@ -249,7 +273,7 @@ export function runInstall(options) {
|
|
|
249
273
|
removeMcpServer(client, action, removed);
|
|
250
274
|
if (action === "install" && node) {
|
|
251
275
|
const addArgs = mcpAddArgs(client, node, entry, endpoint);
|
|
252
|
-
|
|
276
|
+
runClient(client, addArgs, ["ignore", "ignore", "inherit"]);
|
|
253
277
|
process.stdout.write(`Ran: ${client} ${addArgs.join(" ")}\n`);
|
|
254
278
|
process.stdout.write(`Node: ${node}\n`);
|
|
255
279
|
if (VERSIONED_NODE_DIR.test(node)) {
|
package/build/cli/mcp-config.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import { MCP_SERVER_NAME } from "../
|
|
11
|
+
import { MCP_SERVER_NAME } from "../contract.js";
|
|
12
12
|
/** Where the client keeps the MCP server command. */
|
|
13
13
|
export function mcpConfigFile(client) {
|
|
14
14
|
if (client === "claude") {
|
|
@@ -41,22 +41,26 @@ export function readMcpCommand(client, file) {
|
|
|
41
41
|
return undefined;
|
|
42
42
|
return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
|
|
43
43
|
}
|
|
44
|
-
// Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command
|
|
44
|
+
// Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command and args.
|
|
45
45
|
const lines = text.split("\n");
|
|
46
|
-
const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s
|
|
46
|
+
const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.(["']?)${MCP_SERVER_NAME}\\1\\]\\s*(?:#.*)?$`).test(line));
|
|
47
47
|
if (start === -1)
|
|
48
48
|
return undefined;
|
|
49
|
-
|
|
50
|
-
let args = [];
|
|
49
|
+
const body = [];
|
|
51
50
|
for (const line of lines.slice(start + 1)) {
|
|
52
51
|
if (/^\s*\[/.test(line))
|
|
53
52
|
break;
|
|
54
|
-
|
|
55
|
-
if (c)
|
|
56
|
-
command = JSON.parse(`"${c[1]}"`);
|
|
57
|
-
const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
|
|
58
|
-
if (a)
|
|
59
|
-
args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
|
|
53
|
+
body.push(line);
|
|
60
54
|
}
|
|
61
|
-
|
|
55
|
+
const table = body.join("\n");
|
|
56
|
+
const value = String.raw `"(?:[^"\\\r\n]|\\.)*"|'[^'\r\n]*'`;
|
|
57
|
+
const decode = (s) => s.startsWith("'") ? s.slice(1, -1) : JSON.parse(s);
|
|
58
|
+
const command = new RegExp(`^\\s*command\\s*=\\s*(${value})`, "m").exec(table);
|
|
59
|
+
if (!command)
|
|
60
|
+
return undefined;
|
|
61
|
+
// Treat strings and comments as whole tokens so brackets and quotes inside them are not syntax.
|
|
62
|
+
const array = new RegExp(`^\\s*args\\s*=\\s*\\[((?:${value}|#[^\\n]*|[^\\]"'#])*)\\]`, "m").exec(table);
|
|
63
|
+
const args = array ? [...array[1].matchAll(new RegExp(`(${value})|#[^\\n]*`, "g"))]
|
|
64
|
+
.filter((m) => m[1] !== undefined).map((m) => decode(m[1])) : [];
|
|
65
|
+
return { command: decode(command[1]), args };
|
|
62
66
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAN discovery of a host, query side. An n2n-nexus-host bound to a
|
|
3
|
+
* non-loopback address answers UDP queries on the discovery port; a client
|
|
4
|
+
* configured with the endpoint `discover` broadcasts a query, uses the first
|
|
5
|
+
* host that answers and caches it in `~/.n2n-nexus/discovered-endpoint`. When
|
|
6
|
+
* none answers it falls back to the local default endpoint (a host running on
|
|
7
|
+
* this machine); it never starts a host or makes one reachable from the LAN.
|
|
8
|
+
*
|
|
9
|
+
* Node built-ins only: the hook command imports this on every client event.
|
|
10
|
+
*/
|
|
11
|
+
import dgram from "node:dgram";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
/** Endpoint value that turns discovery on (NEXUS_ENDPOINT or --endpoint). */
|
|
16
|
+
export const DISCOVER_ENDPOINT = "discover";
|
|
17
|
+
export const LOCAL_ENDPOINT = "http://127.0.0.1:5688";
|
|
18
|
+
export const DISCOVERY_QUERY = "n2n-nexus/discover";
|
|
19
|
+
const DEFAULT_DISCOVERY_PORT = 5688;
|
|
20
|
+
const DISCOVERY_WAIT_MS = 1000;
|
|
21
|
+
/** How long "no daemon answered" stays cached before the next lookup. */
|
|
22
|
+
const NOT_FOUND_TTL_MS = 60_000;
|
|
23
|
+
const ALL_INTERFACES = new Set(["0.0.0.0", "::", ""]);
|
|
24
|
+
/** UDP port of discovery queries (NEXUS_DISCOVERY_PORT, default 5688). */
|
|
25
|
+
export function discoveryPort() {
|
|
26
|
+
const parsed = parseInt(process.env.NEXUS_DISCOVERY_PORT || "", 10);
|
|
27
|
+
return parsed > 0 ? parsed : DEFAULT_DISCOVERY_PORT;
|
|
28
|
+
}
|
|
29
|
+
/** IPv4 LAN interfaces of this machine. */
|
|
30
|
+
function lanInterfaces() {
|
|
31
|
+
return Object.values(os.networkInterfaces()).flatMap((addresses) => addresses ?? [])
|
|
32
|
+
.filter((a) => a.family === "IPv4" && !a.internal);
|
|
33
|
+
}
|
|
34
|
+
/** Directed broadcast address of every IPv4 LAN interface, plus the limited broadcast and loopback. */
|
|
35
|
+
function queryTargets() {
|
|
36
|
+
const targets = new Set(["255.255.255.255", "127.0.0.1"]);
|
|
37
|
+
for (const a of lanInterfaces()) {
|
|
38
|
+
const ip = a.address.split(".").map(Number);
|
|
39
|
+
const mask = a.netmask.split(".").map(Number);
|
|
40
|
+
targets.add(ip.map((octet, i) => (octet | (~mask[i] & 255))).join("."));
|
|
41
|
+
}
|
|
42
|
+
return [...targets];
|
|
43
|
+
}
|
|
44
|
+
/** Broadcast a query and return the endpoint of the first daemon that answers, or null. */
|
|
45
|
+
export function discoverEndpoint(udpPort = discoveryPort(), waitMs = DISCOVERY_WAIT_MS) {
|
|
46
|
+
// A daemon on this machine answers from loopback or one of its own addresses: use loopback when it
|
|
47
|
+
// listens on all interfaces, else the one address it is bound to.
|
|
48
|
+
const own = new Set(lanInterfaces().map((a) => a.address));
|
|
49
|
+
const address = (rinfo, reply) => {
|
|
50
|
+
if (!own.has(rinfo.address) && !rinfo.address.startsWith("127."))
|
|
51
|
+
return rinfo.address;
|
|
52
|
+
return typeof reply.host === "string" && !ALL_INTERFACES.has(reply.host) ? reply.host : "127.0.0.1";
|
|
53
|
+
};
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const socket = dgram.createSocket("udp4");
|
|
56
|
+
let done = false;
|
|
57
|
+
const finish = (endpoint) => {
|
|
58
|
+
if (done)
|
|
59
|
+
return;
|
|
60
|
+
done = true;
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
socket.close();
|
|
63
|
+
resolve(endpoint);
|
|
64
|
+
};
|
|
65
|
+
const timer = setTimeout(() => finish(null), waitMs);
|
|
66
|
+
socket.on("error", () => finish(null));
|
|
67
|
+
socket.on("message", (msg, rinfo) => {
|
|
68
|
+
try {
|
|
69
|
+
const reply = JSON.parse(msg.toString("utf8"));
|
|
70
|
+
if (reply.service === "n2n-nexus" && Number.isInteger(reply.port) && reply.port > 0) {
|
|
71
|
+
finish(`http://${address(rinfo, reply)}:${reply.port}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* not a discovery reply */ }
|
|
75
|
+
});
|
|
76
|
+
socket.bind(0, () => {
|
|
77
|
+
socket.setBroadcast(true);
|
|
78
|
+
const query = Buffer.from(DISCOVERY_QUERY);
|
|
79
|
+
for (const target of queryTargets())
|
|
80
|
+
socket.send(query, udpPort, target, () => { });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const cacheFile = () => path.join(os.homedir(), ".n2n-nexus", "discovered-endpoint");
|
|
85
|
+
/** The cached lookup result and its age, or null when there is none. */
|
|
86
|
+
export function cachedEndpoint() {
|
|
87
|
+
try {
|
|
88
|
+
const endpoint = fs.readFileSync(cacheFile(), "utf8").trim();
|
|
89
|
+
return endpoint ? { endpoint, ageMs: Date.now() - fs.statSync(cacheFile()).mtimeMs } : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The endpoint to use for a configured value: anything but `discover` as is.
|
|
97
|
+
* For `discover`: the cached result unless `fresh`, else a lookup. A found
|
|
98
|
+
* daemon stays cached until a client fails to reach it and looks again; "none
|
|
99
|
+
* answered" (the local default) is cached for NOT_FOUND_TTL_MS so hooks do not
|
|
100
|
+
* wait for a lookup on every event.
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveEndpoint(configured, options = {}) {
|
|
103
|
+
if (configured !== DISCOVER_ENDPOINT)
|
|
104
|
+
return configured;
|
|
105
|
+
const cached = options.fresh ? null : cachedEndpoint();
|
|
106
|
+
if (cached && (cached.endpoint !== LOCAL_ENDPOINT || cached.ageMs < NOT_FOUND_TTL_MS))
|
|
107
|
+
return cached.endpoint;
|
|
108
|
+
const endpoint = await discoverEndpoint() ?? LOCAL_ENDPOINT;
|
|
109
|
+
try {
|
|
110
|
+
fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
|
|
111
|
+
fs.writeFileSync(cacheFile(), endpoint + "\n");
|
|
112
|
+
}
|
|
113
|
+
catch { /* caching is best effort */ }
|
|
114
|
+
return endpoint;
|
|
115
|
+
}
|
|
@@ -11,6 +11,8 @@ export class NexusTransportError extends Error {
|
|
|
11
11
|
this.name = "NexusTransportError";
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/** NexusTransportError code when the daemon did not answer within the request timeout. */
|
|
15
|
+
export const REQUEST_TIMEOUT_CODE = "EREQUESTTIMEOUT";
|
|
14
16
|
/** The daemon answered, but the tool call itself failed (unknown tool, validation, not found, ...). */
|
|
15
17
|
export class NexusToolError extends Error {
|
|
16
18
|
status;
|
|
@@ -73,12 +75,16 @@ export class NexusClient {
|
|
|
73
75
|
});
|
|
74
76
|
});
|
|
75
77
|
req.on("error", (e) => reject(e instanceof NexusTransportError ? e : new NexusTransportError(e.message, e.code)));
|
|
76
|
-
req.on("timeout", () => req.destroy(new NexusTransportError(`Request to ${this.endpoint} timed out after ${timeoutMs} ms
|
|
78
|
+
req.on("timeout", () => req.destroy(new NexusTransportError(`Request to ${this.endpoint} timed out after ${timeoutMs} ms`, REQUEST_TIMEOUT_CODE)));
|
|
77
79
|
if (payload)
|
|
78
80
|
req.write(payload);
|
|
79
81
|
req.end();
|
|
80
82
|
});
|
|
81
83
|
}
|
|
84
|
+
/** GET /health: n2n-nexus-host answers `{ ok, service: "n2n-nexus", version, contractVersion, sessions: { online } }`. */
|
|
85
|
+
async health() {
|
|
86
|
+
return this.request("GET", "/health");
|
|
87
|
+
}
|
|
82
88
|
async fetchTools() {
|
|
83
89
|
const res = await this.request("GET", "/api/tools");
|
|
84
90
|
return res.tools;
|
|
@@ -88,7 +94,7 @@ export class NexusClient {
|
|
|
88
94
|
return this.request("POST", "/api/hook", request, timeoutMs);
|
|
89
95
|
}
|
|
90
96
|
async callTool(name, args, instanceId, caller) {
|
|
91
|
-
const res = await this.request("POST", "/api/tools/call", { tool: name, args: args || {}, instanceId
|
|
97
|
+
const res = await this.request("POST", "/api/tools/call", { tool: name, args: args || {}, ...(instanceId ? { instanceId } : {}), ...(caller ? { caller } : {}) });
|
|
92
98
|
if (!res.ok)
|
|
93
99
|
throw new NexusToolError(res.error || "Tool call failed", 200);
|
|
94
100
|
return res.result;
|