n2n-nexus 0.8.2 → 0.9.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/README.md +159 -78
  4. package/SECURITY.md +37 -0
  5. package/build/cli/client-command.js +58 -0
  6. package/build/cli/doctor.js +58 -8
  7. package/build/cli/entry.js +5 -0
  8. package/build/cli/hook.js +106 -18
  9. package/build/cli/install.js +37 -13
  10. package/build/cli/mcp-config.js +16 -12
  11. package/build/client/discovery.js +115 -0
  12. package/build/client/nexus-client.js +10 -4
  13. package/build/contract.js +69 -0
  14. package/build/index.js +12 -18
  15. package/build/server/nexus.js +66 -35
  16. package/contract/errors.json +52 -0
  17. package/contract/hook.json +156 -0
  18. package/contract/tools.json +2824 -0
  19. package/docs/ARCHITECTURE.md +142 -108
  20. package/docs/ARCHITECTURE_zh.md +144 -110
  21. package/docs/ASSISTANT_GUIDE.md +20 -12
  22. package/docs/ASSISTANT_GUIDE_zh.md +141 -0
  23. package/docs/MEETING_MINUTES_2025-12-29.md +166 -0
  24. package/docs/README_zh.md +160 -79
  25. package/docs/TOOLS_REFERENCE.md +419 -0
  26. package/docs/discussion_2025-12-29.md +727 -0
  27. package/llms.txt +21 -5
  28. package/package.json +12 -8
  29. package/build/cli/autostart.js +0 -64
  30. package/build/config/cli.js +0 -8
  31. package/build/config/paths.js +0 -38
  32. package/build/constants.js +0 -18
  33. package/build/daemon/hook-contract.js +0 -68
  34. package/build/daemon/index.js +0 -57
  35. package/build/daemon/rate-limit.js +0 -62
  36. package/build/daemon/server.js +0 -862
  37. package/build/daemon/tool-outputs.js +0 -180
  38. package/build/daemon/tools.js +0 -326
  39. package/build/storage/agents.js +0 -227
  40. package/build/storage/docs.js +0 -81
  41. package/build/storage/events.js +0 -31
  42. package/build/storage/fs-utils.js +0 -88
  43. package/build/storage/global-messages.js +0 -44
  44. package/build/storage/index.js +0 -141
  45. package/build/storage/migrations.js +0 -87
  46. package/build/storage/paths.js +0 -23
  47. package/build/storage/projects.js +0 -53
  48. package/build/storage/registry.js +0 -355
  49. package/build/storage/sqlite-meeting.js +0 -323
  50. package/build/storage/sqlite.js +0 -275
  51. package/build/storage/tasks.js +0 -138
  52. package/build/types.js +0 -1
  53. package/build/utils/async-rwlock.js +0 -64
@@ -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, and say how to fix what does not. Without a
5
- * client both are checked; a client with nothing installed is only reported
6
- * when it was named. Exit code 1 when a check failed.
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 "../daemon/hook-contract.js";
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
- /** Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed. */
42
- function doctorClient(client) {
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) {
@@ -0,0 +1,5 @@
1
+ import { fileURLToPath } from "node:url";
2
+ /** Absolute path of the package entry script (`build/index.js` next to `build/cli/`). */
3
+ export function entryScript() {
4
+ return fileURLToPath(new URL("../index.js", import.meta.url));
5
+ }
package/build/cli/hook.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
- * `n2n-nexus hook <claude|codex> [--endpoint <url>]`: forward one client hook
3
- * event to the daemon and print its output. Runs on every tool call, so it
4
- * loads only node built-ins (no MCP SDK, zod or SQLite), and every failure is
5
- * silent: exit 0 with nothing on stdout, so the client carries on unaffected.
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 { HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, isHookClient } from "../daemon/hook-contract.js";
8
- import { NexusClient, NexusTransportError } from "../client/nexus-client.js";
9
- const DEFAULT_ENDPOINT = "http://127.0.0.1:5688";
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 endpoint = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || DEFAULT_ENDPOINT;
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,31 +39,115 @@ 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
- const nexus = new NexusClient({ endpoint });
39
- const send = () => nexus.hook({ client, input }, timeoutMs);
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 n2n-nexus-host 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
- if (!(error instanceof NexusTransportError && error.code === "ECONNREFUSED"))
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 { startLocalDaemon } = await import("./autostart.js");
48
- if (!await startLocalDaemon(endpoint))
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
- throw new Error("invalid response from daemon");
71
+ throw new Error("invalid response from n2n-nexus-host");
54
72
  const { output } = response;
55
73
  if (output === null)
56
74
  return "";
57
75
  if (!isPlainObject(output))
58
- throw new Error("invalid output from daemon");
76
+ throw new Error("invalid output from n2n-nexus-host");
59
77
  return JSON.stringify(output) + "\n";
60
78
  }
61
- /** Run the hook command; always resolves to exit code 0. */
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);
@@ -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 { execFileSync, spawnSync } from "node:child_process";
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 "../daemon/hook-contract.js";
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 { entryScript } from "./autostart.js";
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 = /\/Cellar\/|\/\.nvm\/versions\/|\/node-v\d|\/fnm\/|\/fnm_multishells\/|\/\.volta\/|\/\.asdf\/installs\//;
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 = /\/fnm_multishells\//;
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
- execFileSync(client, removeArgs, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
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" && entry.includes("/_npx/")) {
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 = spawnSync(client, ["--version"], { stdio: "ignore" });
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
- addOurEntries(config, client, command);
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
- execFileSync(client, addArgs, { stdio: ["ignore", "ignore", "inherit"] });
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)) {
@@ -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 "../daemon/hook-contract.js";
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 = "..." and args = [...].
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*$`).test(line));
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
- let command;
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
- const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
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
- return command === undefined ? undefined : { command, args };
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;
@@ -48,7 +50,7 @@ export class NexusClient {
48
50
  }, (res) => {
49
51
  let data = "";
50
52
  res.setEncoding("utf8");
51
- const interrupted = () => reject(new NexusTransportError("Response from daemon ended before completion"));
53
+ const interrupted = () => reject(new NexusTransportError("Response from n2n-nexus-host ended before completion"));
52
54
  res.on("aborted", interrupted);
53
55
  res.on("error", (error) => reject(new NexusTransportError(error.message)));
54
56
  res.on("close", () => { if (!res.complete)
@@ -60,7 +62,7 @@ export class NexusClient {
60
62
  parsed = data ? JSON.parse(data) : {};
61
63
  }
62
64
  catch {
63
- reject(new NexusTransportError(`Invalid JSON response from daemon: ${data.slice(0, 200)}`));
65
+ reject(new NexusTransportError(`Invalid JSON response from n2n-nexus-host: ${data.slice(0, 200)}`));
64
66
  return;
65
67
  }
66
68
  const status = res.statusCode ?? 0;
@@ -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: instanceId || "unknown", ...(caller ? { caller } : {}) });
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;