n2n-nexus 0.8.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.1] - 2026-10-22
6
+
7
+ ### Added
8
+
9
+ - `n2n-nexus install <claude|codex>` writes the `node` found on PATH when it is the running binary, instead of `process.execPath`, the resolved version directory (nvm, Homebrew Cellar, fnm, ...) that disappears when Node is upgraded. `--node <path>` chooses the path explicitly; it must exist, run and satisfy `engines.node`. `install` prints the path it wrote and warns when it is in a version directory.
10
+ - `n2n-nexus doctor [claude|codex]`: checks that the Node path and the entry script in the installed hook and MCP commands exist and run, and says how to fix them.
11
+
12
+ ### Fixed
13
+
14
+ - Daemon responses are decoded as streaming UTF-8; a multi-byte character split across two chunks no longer turns into U+FFFD.
15
+ - `rename_project` renames the directory, the manifests and the registry entry as one unit and rolls everything back when a step fails.
16
+ - `host_delete_project` fails, and keeps the registry entry, when the project files cannot be removed.
17
+ - `upload_project_asset` checks that the project exists inside the write lock, so an upload racing a delete no longer recreates the deleted project. It returns the name the asset is saved under, and a file name that sanitizes to `.` is rejected.
18
+ - `listen({ on: false })` ends a Stop hook that is already waiting instead of letting it run to the timeout.
19
+
20
+ ### Upgrade notes
21
+
22
+ - Run `n2n-nexus install claude` and/or `n2n-nexus install codex` again so the hooks and the MCP command use a stable Node path.
23
+
5
24
  ## [0.8.0] - 2026-10-21
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -105,6 +105,9 @@ n2n-nexus install codex # ~/.codex/hooks.json (or $CODEX_HOME) + codex mcp a
105
105
 
106
106
  - Codex runs new hooks only after you trust them: open Codex and run `/hooks` once.
107
107
  - Pass `--endpoint <url>` to both commands when the daemon is not at `http://127.0.0.1:5688`.
108
+ - The hooks and the MCP server command use an absolute Node path, because GUI clients started from the Dock (such as Claude Desktop) do not read your shell's `PATH`. By default `install` writes the `node` found on `PATH` when it is the same file as the Node running `install` (for example `/usr/local/bin/node`, `/opt/homebrew/bin/node`, `~/.local/bin/node` or a version manager's stable entry point), and otherwise the real path of the running Node. `install` prints the path it wrote as `Node: <path>`.
109
+ - Pass `--node <path>` to choose the Node path yourself. It must exist, be executable and satisfy the `engines` range in package.json (`^22.14.0 || >=23.6.0`); otherwise `install` exits with code 1 and writes nothing.
110
+ - After upgrading Node: if `install` warned that the Node path contains a version directory (Homebrew `Cellar`, nvm, fnm, Volta, asdf, `node-v<version>`), run `n2n-nexus install <claude|codex>` again after each upgrade, or pass `--node` with a stable path. `n2n-nexus doctor [claude|codex]` checks that the Node path and the entry script in the installed hook and MCP commands still exist and run, and exits with code 1 when they do not.
108
111
  - `n2n-nexus uninstall <claude|codex>` removes the hooks and the MCP server that `install` added and leaves the rest of the configuration alone.
109
112
  - Supported on Linux, WSL and macOS. Other MCP clients can use every tool except direct messages.
110
113
 
@@ -191,9 +194,13 @@ n2n-nexus daemon [--port 5688] [--root ~/.n2n-nexus] [--host 127.0.0.1]
191
194
  NEXUS_ENDPOINT=http://127.0.0.1:5688 n2n-nexus mcp
192
195
 
193
196
  # Install / remove the hooks and MCP server for Claude Code or Codex
194
- n2n-nexus install <claude|codex> [--endpoint <url>]
197
+ # --node <path>: the Node executable to write into the client configuration
198
+ n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
195
199
  n2n-nexus uninstall <claude|codex>
196
200
 
201
+ # Check that the installed hook and MCP commands still run (exit 1 when not)
202
+ n2n-nexus doctor [claude|codex]
203
+
197
204
  # Hook entry point (written into the client configuration by install)
198
205
  n2n-nexus hook <claude|codex> [--endpoint <url>]
199
206
  ```
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `n2n-nexus doctor [claude|codex]`: check that the Node path and the entry
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.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
13
+ import { pkg } from "../config/index.js";
14
+ import { InstallError, VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
15
+ const ok = (text) => ({ ok: true, text });
16
+ const bad = (text) => ({ ok: false, text });
17
+ const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
18
+ /** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
19
+ export function parseHookCommand(command) {
20
+ const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
21
+ return m ? { node: m[1], entry: m[2] } : undefined;
22
+ }
23
+ export function checkNode(node) {
24
+ if (!fs.existsSync(node))
25
+ return bad("missing");
26
+ try {
27
+ fs.accessSync(node, fs.constants.X_OK);
28
+ }
29
+ catch {
30
+ return bad("not executable");
31
+ }
32
+ const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
33
+ const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
34
+ if (!version)
35
+ return bad("does not run");
36
+ const text = probe.stdout.trim();
37
+ if (!satisfiesEngines(text))
38
+ return bad(`${text} does not satisfy ${pkg.engines.node}`);
39
+ return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
40
+ }
41
+ const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
42
+ /** Where the client keeps the MCP server command. */
43
+ export function mcpConfigFile(client) {
44
+ if (client === "claude") {
45
+ return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
46
+ }
47
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
48
+ }
49
+ /** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
50
+ export function readMcpCommand(client, file) {
51
+ let text;
52
+ try {
53
+ text = fs.readFileSync(file, "utf8");
54
+ }
55
+ catch (error) {
56
+ if (error.code === "ENOENT")
57
+ return undefined;
58
+ throw error;
59
+ }
60
+ if (client === "claude") {
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(text);
64
+ }
65
+ catch (error) {
66
+ throw new InstallError(`${file} is not valid JSON (${error.message}).`);
67
+ }
68
+ const servers = parsed?.mcpServers;
69
+ const server = servers?.[MCP_SERVER_NAME];
70
+ if (!server || typeof server.command !== "string")
71
+ return undefined;
72
+ return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
73
+ }
74
+ // Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
75
+ const lines = text.split("\n");
76
+ const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
77
+ if (start === -1)
78
+ return undefined;
79
+ let command;
80
+ let args = [];
81
+ for (const line of lines.slice(start + 1)) {
82
+ if (/^\s*\[/.test(line))
83
+ break;
84
+ const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
85
+ if (c)
86
+ command = JSON.parse(`"${c[1]}"`);
87
+ const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
88
+ if (a)
89
+ args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
90
+ }
91
+ return command === undefined ? undefined : { command, args };
92
+ }
93
+ /** Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed. */
94
+ function doctorClient(client) {
95
+ const hooksFile = configFile(client);
96
+ const mcpFile = mcpConfigFile(client);
97
+ const lines = [];
98
+ let failed = false;
99
+ let versioned = false;
100
+ const report = (label, check) => {
101
+ lines.push(` ${check.ok ? "ok " : "FAIL"} ${label}: ${check.text}`);
102
+ if (!check.ok)
103
+ failed = true;
104
+ if (check.text.includes(VERSIONED_NOTE))
105
+ versioned = true;
106
+ };
107
+ const hooks = readConfig(hooksFile).hooks ?? {};
108
+ const commands = new Set();
109
+ for (const groups of Object.values(hooks)) {
110
+ if (!Array.isArray(groups))
111
+ continue;
112
+ for (const group of groups) {
113
+ const entries = group?.hooks;
114
+ if (!Array.isArray(entries))
115
+ continue;
116
+ for (const entry of entries) {
117
+ if (isOurs(entry, client))
118
+ commands.add(entry.command);
119
+ }
120
+ }
121
+ }
122
+ const mcp = readMcpCommand(client, mcpFile);
123
+ if (commands.size === 0 && !mcp)
124
+ return undefined;
125
+ lines.push(`${client}`);
126
+ if (commands.size === 0)
127
+ report(`hooks in ${hooksFile}`, bad("not installed"));
128
+ for (const command of commands) {
129
+ const parsed = parseHookCommand(command);
130
+ if (!parsed) {
131
+ report(`hook command ${command}`, bad("not in the form \"<node>\" \"<entry>\" hook ..."));
132
+ continue;
133
+ }
134
+ report(`hook Node ${parsed.node}`, checkNode(parsed.node));
135
+ report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
136
+ }
137
+ if (!mcp) {
138
+ report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
139
+ }
140
+ else {
141
+ report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
142
+ const entry = mcp.args[0];
143
+ if (entry)
144
+ report(`MCP entry ${entry}`, checkEntry(entry));
145
+ else
146
+ report(`MCP command in ${mcpFile}`, bad("has no entry script argument"));
147
+ }
148
+ if (failed) {
149
+ lines.push(` fix: run "n2n-nexus install ${client}" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
150
+ }
151
+ else if (versioned) {
152
+ lines.push(` note: after upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.`);
153
+ }
154
+ process.stdout.write(lines.join("\n") + "\n");
155
+ return !failed;
156
+ }
157
+ /** Parse `[claude|codex]`, run the checks and return the exit code. */
158
+ export function runDoctor(argv) {
159
+ const client = argv[0];
160
+ if (client !== undefined && !isHookClient(client)) {
161
+ process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
162
+ return 1;
163
+ }
164
+ try {
165
+ let failed = false;
166
+ for (const c of client ? [client] : HOOK_CLIENTS) {
167
+ const result = doctorClient(c);
168
+ if (result === undefined) {
169
+ if (client) {
170
+ process.stdout.write(`${c}\n FAIL nothing installed: run "n2n-nexus install ${c}".\n`);
171
+ failed = true;
172
+ }
173
+ else {
174
+ process.stdout.write(`${c}\n not installed\n`);
175
+ }
176
+ }
177
+ else if (!result) {
178
+ failed = true;
179
+ }
180
+ }
181
+ return failed ? 1 : 0;
182
+ }
183
+ catch (error) {
184
+ process.stderr.write(`[n2n-nexus] doctor failed: ${error.message}\n`);
185
+ return 1;
186
+ }
187
+ }
@@ -1,35 +1,106 @@
1
1
  /**
2
- * `n2n-nexus install <claude|codex> [--endpoint <url>]` and
2
+ * `n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]` and
3
3
  * `n2n-nexus uninstall <claude|codex>`: register the hooks and the MCP server
4
4
  * with Claude Code or Codex. Our hook entries are recognised by a command that
5
5
  * contains both `n2n-nexus` and ` hook <client>`; everything else in the
6
6
  * configuration file is kept as it is.
7
+ *
8
+ * The commands start with an absolute Node path: GUI clients started from the
9
+ * Dock do not read the shell PATH. The `node` found on PATH is preferred when it
10
+ * is the running binary, because process.execPath is the resolved, versioned
11
+ * location (nvm, Homebrew Cellar, ...) that disappears when Node is upgraded.
7
12
  */
8
13
  import { execFileSync, spawnSync } from "node:child_process";
9
14
  import fs from "node:fs";
10
15
  import os from "node:os";
11
16
  import path from "node:path";
12
17
  import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
18
+ import { pkg } from "../config/index.js";
13
19
  import { entryScript } from "./autostart.js";
14
20
  const EVENTS = {
15
21
  claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
16
22
  codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
17
23
  };
18
- class InstallError extends Error {
24
+ export class InstallError extends Error {
19
25
  }
20
- function configFile(client) {
26
+ /** Path segments of Node installs that are replaced on upgrade. */
27
+ export const VERSIONED_NODE_DIR = /\/Cellar\/|\/\.nvm\/versions\/|\/node-v\d|\/fnm\/|\/fnm_multishells\/|\/\.volta\/|\/\.asdf\/installs\//;
28
+ /** fnm puts a per-shell symlink directory first on PATH; it is gone when the shell ends. */
29
+ const PER_SHELL_NODE_DIR = /\/fnm_multishells\//;
30
+ function sameFile(a, b) {
31
+ try {
32
+ return fs.realpathSync(a) === fs.realpathSync(b);
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * The `node` on PATH when it is the running binary (its stable entry), else
40
+ * process.execPath. Relative PATH entries and fnm's per-shell directory are
41
+ * skipped: the commands must work from any directory and after the shell ends.
42
+ */
43
+ export function stableNodePath(env = process.env) {
44
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
45
+ if (!path.isAbsolute(dir) || PER_SHELL_NODE_DIR.test(dir))
46
+ continue;
47
+ const candidate = path.join(dir, "node");
48
+ if (sameFile(candidate, process.execPath))
49
+ return candidate;
50
+ }
51
+ return process.execPath;
52
+ }
53
+ export function parseVersion(text) {
54
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
55
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
56
+ }
57
+ const atLeast = (v, min) => v[0] !== min[0] ? v[0] > min[0] : v[1] !== min[1] ? v[1] > min[1] : v[2] >= min[2];
58
+ /** Whether `version` satisfies the package's engines.node range (`^a.b.c` and `>=a.b.c` clauses joined by `||`). */
59
+ export function satisfiesEngines(version, range = pkg.engines.node) {
60
+ const v = parseVersion(version);
61
+ if (!v)
62
+ return false;
63
+ return range.split("||").some((clause) => {
64
+ const m = /^\s*(\^|>=)\s*v?(\d+\.\d+\.\d+)\s*$/.exec(clause);
65
+ const min = m && parseVersion(m[2]);
66
+ if (!m || !min)
67
+ throw new Error(`Unsupported engines.node clause: ${clause.trim()}`);
68
+ return atLeast(v, min) && (m[1] === ">=" || v[0] === min[0]);
69
+ });
70
+ }
71
+ /** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
72
+ function checkNodePath(given) {
73
+ const node = path.resolve(given);
74
+ try {
75
+ fs.accessSync(node, fs.constants.X_OK);
76
+ }
77
+ catch {
78
+ throw new InstallError(`--node ${given}: not an executable file.`);
79
+ }
80
+ if (fs.statSync(node).isDirectory())
81
+ throw new InstallError(`--node ${given}: is a directory, not the node executable.`);
82
+ const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
83
+ const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
84
+ if (!version)
85
+ throw new InstallError(`--node ${given}: "${node} --version" did not print a Node version.`);
86
+ if (!satisfiesEngines(probe.stdout)) {
87
+ throw new InstallError(`--node ${given}: Node ${probe.stdout.trim()} does not satisfy ${pkg.engines.node}.`);
88
+ }
89
+ return node;
90
+ }
91
+ export function configFile(client) {
21
92
  if (client === "claude") {
22
93
  return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json");
23
94
  }
24
95
  return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json");
25
96
  }
26
- function isOurs(entry, client) {
97
+ export function isOurs(entry, client) {
27
98
  if (typeof entry !== "object" || entry === null)
28
99
  return false;
29
100
  const command = entry.command;
30
101
  return typeof command === "string" && command.includes("n2n-nexus") && command.includes(` hook ${client}`);
31
102
  }
32
- function readConfig(file) {
103
+ export function readConfig(file) {
33
104
  let text;
34
105
  try {
35
106
  text = fs.readFileSync(file, "utf8");
@@ -111,10 +182,10 @@ function mcpRemoveArgs(client) {
111
182
  ? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
112
183
  : ["mcp", "remove", MCP_SERVER_NAME];
113
184
  }
114
- function mcpAddArgs(client, entry, endpoint) {
185
+ function mcpAddArgs(client, node, entry, endpoint) {
115
186
  const env = endpoint ? [client === "claude" ? "-e" : "--env", `NEXUS_ENDPOINT=${endpoint}`] : [];
116
187
  const scope = client === "claude" ? ["--scope", "user"] : [];
117
- return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", process.execPath, entry, "mcp"];
188
+ return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", node, entry, "mcp"];
118
189
  }
119
190
  /** Install or uninstall; prints what it did and returns the exit code. */
120
191
  export function runInstall(options) {
@@ -128,11 +199,12 @@ export function runInstall(options) {
128
199
  if (probe.error || probe.status !== 0) {
129
200
  throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
130
201
  }
202
+ const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
131
203
  const file = configFile(client);
132
204
  const config = readConfig(file);
133
205
  const removed = removeOurEntries(config, client, file);
134
206
  if (action === "install") {
135
- const command = `"${process.execPath}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
207
+ const command = `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
136
208
  addOurEntries(config, client, command);
137
209
  }
138
210
  if (action === "install" || removed) {
@@ -148,10 +220,14 @@ export function runInstall(options) {
148
220
  catch {
149
221
  // Not registered yet (or already removed): nothing to undo.
150
222
  }
151
- if (action === "install") {
152
- const addArgs = mcpAddArgs(client, entry, endpoint);
223
+ if (action === "install" && node) {
224
+ const addArgs = mcpAddArgs(client, node, entry, endpoint);
153
225
  execFileSync(client, addArgs, { stdio: ["ignore", "ignore", "inherit"] });
154
226
  process.stdout.write(`Ran: ${client} ${addArgs.join(" ")}\n`);
227
+ process.stdout.write(`Node: ${node}\n`);
228
+ if (VERSIONED_NODE_DIR.test(node)) {
229
+ process.stdout.write(`Note: this Node path contains a version directory that an upgrade may remove. After upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.\n`);
230
+ }
155
231
  if (client === "codex") {
156
232
  process.stdout.write("Next: open Codex and run /hooks to trust the n2n-nexus hooks; until then they do not run.\n");
157
233
  }
@@ -163,18 +239,29 @@ export function runInstall(options) {
163
239
  return 1;
164
240
  }
165
241
  }
166
- /** Parse `<claude|codex> [--endpoint <url>]` and run. */
242
+ /** Parse `<claude|codex> [--endpoint <url>] [--node <path>]` and run. */
167
243
  export function runInstallCommand(action, argv) {
168
244
  const client = argv[0];
169
245
  if (!isHookClient(client)) {
170
- process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>]" : ""}\n`);
246
+ process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
171
247
  return 1;
172
248
  }
173
- const i = argv.indexOf("--endpoint");
174
- const endpoint = action === "install" && i !== -1 ? argv[i + 1] : undefined;
175
- if (i !== -1 && action === "install" && !endpoint) {
176
- process.stderr.write("--endpoint needs a URL\n");
249
+ const option = (name, what) => {
250
+ const i = argv.indexOf(name);
251
+ if (i === -1 || action !== "install")
252
+ return undefined;
253
+ const value = argv[i + 1];
254
+ if (!value || value.startsWith("--")) {
255
+ process.stderr.write(`${name} needs ${what}\n`);
256
+ return null;
257
+ }
258
+ return value;
259
+ };
260
+ const endpoint = option("--endpoint", "a URL");
261
+ if (endpoint === null)
177
262
  return 1;
178
- }
179
- return runInstall({ action, client, endpoint });
263
+ const node = option("--node", "a path");
264
+ if (node === null)
265
+ return 1;
266
+ return runInstall({ action, client, endpoint, node });
180
267
  }
@@ -47,6 +47,7 @@ export class NexusClient {
47
47
  timeout: timeoutMs
48
48
  }, (res) => {
49
49
  let data = "";
50
+ res.setEncoding("utf8");
50
51
  const interrupted = () => reject(new NexusTransportError("Response from daemon ended before completion"));
51
52
  res.on("aborted", interrupted);
52
53
  res.on("error", (error) => reject(new NexusTransportError(error.message)));
@@ -35,7 +35,9 @@ async function toolSyncProjectAssets(args, instanceId) {
35
35
  async function toolUploadAsset(args, instanceId) {
36
36
  if (!await StorageManager.getProjectManifest(args.projectId))
37
37
  throw new Error(`Project '${args.projectId}' not found.`);
38
- const sanitized = args.fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
38
+ // Must produce the final saved name: StorageManager.saveAsset's sanitizeFileName
39
+ // (src/storage/projects.ts) has to leave this result unchanged, so keep both rules in step.
40
+ const sanitized = args.fileName.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/\.\./g, "_");
39
41
  const buf = Buffer.from(args.base64Content, "base64");
40
42
  await StorageManager.saveAsset(args.projectId, sanitized, buf);
41
43
  EventStore.record(instanceId, "project.asset", args.projectId, { fileName: sanitized, bytes: buf.length });
@@ -282,6 +284,7 @@ async function toolRead(args, caller) {
282
284
  async function toolListen(args, caller) {
283
285
  const self = resolveAgent(caller);
284
286
  AgentStore.setListening(self.id, args.on);
287
+ wake([self.id]);
285
288
  return { listening: args.on };
286
289
  }
287
290
  async function toolSetProfile(args, caller) {
@@ -354,8 +357,9 @@ function waitForMessages(sessionId, waitMs, signal) {
354
357
  if (done)
355
358
  return;
356
359
  // SessionEnd (or listen off) during the wait: nobody will act on a block any more.
360
+ // stopWaiting sets idle only while still "listening", so SessionEnd stays offline.
357
361
  if (!AgentStore.get(sessionId)?.listening)
358
- return finish(null);
362
+ return stopWaiting();
359
363
  const text = notifyFresh(sessionId, STOP_CLOSING);
360
364
  if (text === null)
361
365
  return;
package/build/index.js CHANGED
@@ -12,10 +12,13 @@ n2n-nexus v${pkg.version} — Multi-AI assistant coordination hub
12
12
  USAGE:
13
13
  n2n-nexus daemon Start the Nexus server (run once, stays alive)
14
14
  n2n-nexus mcp Start the MCP proxy (launched by IDE automatically)
15
- n2n-nexus install <claude|codex> [--endpoint <url>]
15
+ n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
16
16
  Register the hooks and the MCP server with Claude Code or Codex
17
17
  n2n-nexus uninstall <claude|codex>
18
18
  Remove the hooks and the MCP server installed by install
19
+ n2n-nexus doctor [claude|codex]
20
+ Check that the Node path and entry script in the installed hook and
21
+ MCP commands exist and run; exit 1 and say how to fix them when not
19
22
  n2n-nexus hook <claude|codex> [--endpoint <url>]
20
23
  Forward a client hook event to the daemon (run by the hooks)
21
24
 
@@ -26,6 +29,8 @@ OPTIONS:
26
29
  --id <id> Instance ID for MCP proxy (default: mcp-<hostname>-<cwd hash>-<IDE name>)
27
30
  --endpoint <url> Daemon URL for hook (default: NEXUS_ENDPOINT, then http://127.0.0.1:5688);
28
31
  install writes it into the hook and MCP commands
32
+ --node <path> Node executable written into the hook and MCP commands by install
33
+ (default: the node on PATH when it is the running one, else its real path)
29
34
  --version, -v Show version
30
35
  --help, -h Show this help
31
36
 
@@ -47,6 +52,10 @@ async function main() {
47
52
  const { runInstallCommand } = await import("./cli/install.js");
48
53
  process.exit(runInstallCommand(command, process.argv.slice(3)));
49
54
  }
55
+ if (command === "doctor") {
56
+ const { runDoctor } = await import("./cli/doctor.js");
57
+ process.exit(runDoctor(process.argv.slice(3)));
58
+ }
50
59
  if (command === "daemon") {
51
60
  const { runDaemon } = await import("./daemon/index.js");
52
61
  await runDaemon();
@@ -75,13 +75,27 @@ export class StorageManager {
75
75
  // --- Project Methods ---
76
76
  static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
77
77
  static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
78
- static saveAsset(id, fileName, content) { return this.write(() => ProjectStorage.saveAsset(id, fileName, content)); }
78
+ /**
79
+ * The existence check runs inside the write lock: writeFileAtomic creates
80
+ * missing directories, so an upload queued behind a delete would otherwise
81
+ * bring the deleted project's directory back.
82
+ */
83
+ static saveAsset(id, fileName, content) {
84
+ return this.write(async () => {
85
+ const registry = await RegistryStorage.listRegistry();
86
+ if (!registry.projects[id])
87
+ throw new Error(`Project '${id}' not found.`);
88
+ return ProjectStorage.saveAsset(id, fileName, content);
89
+ });
90
+ }
91
+ /** Files first, index last: if the files cannot be removed, the throw leaves the registry entry in place. */
79
92
  static deleteProject(id) {
80
93
  return this.write(async () => {
81
94
  await ProjectStorage.deleteProject(id);
82
95
  await RegistryStorage.deleteProject(id);
83
96
  });
84
97
  }
98
+ /** RegistryStorage.renameProject moves the directory, manifests and registry together and rolls back on failure. */
85
99
  static renameProject(oldId, newId) {
86
100
  return this.write(async () => {
87
101
  if (!await RegistryStorage.getProjectManifest(oldId))
@@ -89,16 +103,7 @@ export class StorageManager {
89
103
  const renamed = await RegistryStorage.renameProject(oldId, newId);
90
104
  if (renamed === 0)
91
105
  throw new Error(`Project '${oldId}' could not be renamed.`);
92
- try {
93
- const result = await ProjectStorage.renameProject(oldId, newId);
94
- if (result === 0)
95
- throw new Error(`Project '${oldId}' could not be renamed.`);
96
- return result;
97
- }
98
- catch (error) {
99
- await RegistryStorage.renameProject(newId, oldId).catch(() => { });
100
- throw error;
101
- }
106
+ return renamed;
102
107
  });
103
108
  }
104
109
  // --- Doc Methods ---
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { NexusPaths } from "./paths.js";
4
4
  import { FILE_ENCODING } from "../constants.js";
5
- import { writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
5
+ import { writeFileAtomic } from "./fs-utils.js";
6
6
  export class ProjectStorage {
7
7
  static projectFilePath(projectId) {
8
8
  return {
@@ -27,43 +27,22 @@ export class ProjectStorage {
27
27
  }
28
28
  static async saveAsset(projectId, fileName, content) {
29
29
  const safeName = this.sanitizeFileName(fileName);
30
+ // "." would resolve to the assets directory itself and writeFileAtomic would create a file in its place.
31
+ if (safeName === ".")
32
+ throw new Error(`Invalid fileName '${fileName}': it names no file.`);
30
33
  const paths = this.projectFilePath(projectId);
31
34
  const assetPath = path.join(paths.assetsDir, safeName);
32
35
  await writeFileAtomic(assetPath, content);
33
36
  return path.relative(NexusPaths.root, assetPath);
34
37
  }
38
+ /**
39
+ * Removes the project directory. A missing directory counts as deleted
40
+ * (force); any other failure is thrown so the caller keeps the registry
41
+ * entry instead of indexing a project whose files are still on disk.
42
+ */
35
43
  static async deleteProject(projectId) {
36
44
  const paths = this.projectFilePath(projectId);
37
- try {
38
- await fs.rm(paths.projectDir, { recursive: true, force: true });
39
- return 1;
40
- }
41
- catch {
42
- return 0;
43
- }
44
- }
45
- static async renameProject(oldId, newId) {
46
- if (!oldId || !newId || oldId === newId)
47
- return 0;
48
- const oldPath = this.projectFilePath(oldId);
49
- const newPath = this.projectFilePath(newId);
50
- try {
51
- await fs.access(oldPath.projectDir);
52
- }
53
- catch {
54
- return 0;
55
- }
56
- await fs.mkdir(path.dirname(newPath.projectDir), { recursive: true });
57
- await fs.rename(oldPath.projectDir, newPath.projectDir);
58
- try {
59
- const manifestRaw = await fs.readFile(newPath.manifestPath, FILE_ENCODING);
60
- const manifest = JSON.parse(manifestRaw);
61
- manifest.id = newId;
62
- await writeJsonAtomic(newPath.manifestPath, manifest);
63
- }
64
- catch {
65
- // Manifest is optional for some flows.
66
- }
45
+ await fs.rm(paths.projectDir, { recursive: true, force: true });
67
46
  return 1;
68
47
  }
69
48
  static sanitizeFileName(fileName) {
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { NexusPaths } from "./paths.js";
3
- import { toDict, writeJsonAtomic } from "./fs-utils.js";
3
+ import { toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
4
4
  const emptyRegistry = () => ({ projects: toDict() });
5
5
  async function readRegistry() {
6
6
  try {
@@ -233,39 +233,97 @@ export class RegistryStorage {
233
233
  await writeRegistry(registry);
234
234
  return 1;
235
235
  }
236
+ /**
237
+ * Renames a project as one unit: its directory, its own manifest id, every
238
+ * relation that targets it in other manifests, and the registry key.
239
+ *
240
+ * Order: directory rename, then manifest writes, then the registry write
241
+ * last. The registry is the index readers trust, so it only switches to
242
+ * the new id once everything it points at is in place. If any step
243
+ * throws, the completed steps are undone in reverse (manifests restored to
244
+ * their original bytes, directory renamed back) and the original error is
245
+ * rethrown. Rollback is best effort: a failure while undoing is swallowed
246
+ * so the caller still sees the error that caused the rollback.
247
+ *
248
+ * Returns 0 when oldId is not registered or its directory does not exist.
249
+ */
236
250
  static async renameProject(oldId, newId) {
251
+ if (!oldId || !newId || oldId === newId)
252
+ return 0;
237
253
  const registry = await readRegistry();
238
254
  if (!registry.projects[oldId])
239
255
  return 0;
240
256
  if (registry.projects[newId]) {
241
257
  throw new Error(`Project '${newId}' already exists.`);
242
258
  }
243
- const entry = registry.projects[oldId];
244
- delete registry.projects[oldId];
245
- registry.projects[newId] = entry;
246
- await writeRegistry(registry);
259
+ const oldDir = NexusPaths.projectDir(oldId);
260
+ const newDir = NexusPaths.projectDir(newId);
261
+ try {
262
+ await fs.access(oldDir);
263
+ }
264
+ catch {
265
+ return 0;
266
+ }
267
+ // Plan every manifest rewrite before touching disk. `original` keeps
268
+ // the exact bytes for rollback; `path` is where the file lives after
269
+ // the directory rename.
270
+ const rewrites = [];
247
271
  for (const projectId of Object.keys(registry.projects)) {
248
- const manifestId = projectId === newId ? oldId : projectId;
249
- const manifest = await readProjectManifest(manifestId);
250
- if (!manifest)
272
+ let original;
273
+ try {
274
+ original = await fs.readFile(NexusPaths.projectManifestPath(projectId), "utf-8");
275
+ }
276
+ catch {
251
277
  continue;
278
+ }
279
+ let manifest;
280
+ try {
281
+ manifest = JSON.parse(original);
282
+ }
283
+ catch {
284
+ continue;
285
+ }
252
286
  let changed = false;
253
- if (projectId === newId && manifest.id !== newId) {
287
+ if (projectId === oldId) {
254
288
  manifest.id = newId;
255
289
  changed = true;
256
290
  }
257
291
  if (Array.isArray(manifest.relations)) {
258
- const relations = manifest.relations.map((relation) => {
259
- if (relation.targetId !== oldId)
292
+ manifest.relations = manifest.relations.map((relation) => {
293
+ if (relation?.targetId !== oldId)
260
294
  return relation;
261
295
  changed = true;
262
296
  return { ...relation, targetId: newId };
263
297
  });
264
- manifest.relations = relations;
265
298
  }
266
299
  if (changed) {
267
- await writeJsonAtomic(NexusPaths.projectManifestPath(manifestId), manifest);
300
+ const target = projectId === oldId ? newId : projectId;
301
+ rewrites.push({ path: NexusPaths.projectManifestPath(target), original, updated: manifest });
302
+ }
303
+ }
304
+ let dirMoved = false;
305
+ const written = [];
306
+ try {
307
+ await fs.mkdir(NexusPaths.projectsRoot, { recursive: true });
308
+ await fs.rename(oldDir, newDir);
309
+ dirMoved = true;
310
+ for (const rewrite of rewrites) {
311
+ await writeJsonAtomic(rewrite.path, rewrite.updated);
312
+ written.push(rewrite);
313
+ }
314
+ const entry = registry.projects[oldId];
315
+ delete registry.projects[oldId];
316
+ registry.projects[newId] = entry;
317
+ await writeRegistry(registry);
318
+ }
319
+ catch (error) {
320
+ for (const rewrite of written.reverse()) {
321
+ await writeFileAtomic(rewrite.path, rewrite.original).catch(() => { });
322
+ }
323
+ if (dirMoved) {
324
+ await fs.rename(newDir, oldDir).catch(() => { });
268
325
  }
326
+ throw error;
269
327
  }
270
328
  return 1;
271
329
  }
@@ -132,7 +132,8 @@ n2n-nexus
132
132
  ├─ n2n-nexus daemon Start once, keep running. Owns all data.
133
133
  ├─ n2n-nexus mcp IDE starts automatically via npx. Stateless proxy.
134
134
  ├─ n2n-nexus hook Run by Claude Code / Codex on each hook event.
135
- └─ n2n-nexus install Write the hooks and MCP server into a client's user configuration.
135
+ ├─ n2n-nexus install Write the hooks and MCP server into a client's user configuration.
136
+ └─ n2n-nexus doctor Check that the installed hook and MCP commands still run.
136
137
  ```
137
138
 
138
139
  **Daemon** (started by the user, or in the background by a hook or adapter when none listens at a local endpoint):
@@ -132,7 +132,8 @@ n2n-nexus
132
132
  ├─ n2n-nexus daemon 启动一次,持续运行,持有所有数据。
133
133
  ├─ n2n-nexus mcp IDE 通过 npx 自动启动,无状态代理。
134
134
  ├─ n2n-nexus hook Claude Code / Codex 在每个 hook 事件时运行。
135
- └─ n2n-nexus install 把 hooks 和 MCP 服务写进客户端的用户级配置。
135
+ ├─ n2n-nexus install 把 hooks 和 MCP 服务写进客户端的用户级配置。
136
+ └─ n2n-nexus doctor 检查已安装的 hook 和 MCP 命令是否还能运行。
136
137
  ```
137
138
 
138
139
  **Daemon**(由用户启动;本机地址上没有 daemon 时,也会由 hook 或适配器在后台拉起):
package/docs/README_zh.md CHANGED
@@ -114,6 +114,9 @@ n2n-nexus install codex # ~/.codex/hooks.json(或 $CODEX_HOME)+ codex mc
114
114
 
115
115
  - Codex 只运行已信任的 hooks:打开 Codex,执行一次 `/hooks` 完成信任。
116
116
  - daemon 不在 `http://127.0.0.1:5688` 时,两条命令都加上 `--endpoint <url>`。
117
+ - hooks 和 MCP 服务的命令里写的是 Node 的绝对路径,因为从 Dock 启动的 GUI 客户端(如 Claude 桌面版)不读取 shell 的 `PATH`。默认情况下,`PATH` 上的 `node` 与运行 `install` 的 Node 是同一个文件时,`install` 写入 `PATH` 上的路径(例如 `/usr/local/bin/node`、`/opt/homebrew/bin/node`、`~/.local/bin/node` 或版本管理工具的稳定入口),否则写入当前运行的 Node 的真实路径。`install` 会打印一行 `Node: <path>` 显示实际写入的路径。
118
+ - 用 `--node <path>` 手动指定 Node 路径。路径必须存在、可执行,且版本满足 package.json 的 `engines`(`^22.14.0 || >=23.6.0`),否则 `install` 以退出码 1 退出,不写任何配置。
119
+ - 升级 Node 后:如果 `install` 提示 Node 路径位于带版本号的目录(Homebrew `Cellar`、nvm、fnm、Volta、asdf、`node-v<版本>`),每次升级 Node 后都要重新运行 `n2n-nexus install <claude|codex>`,或用 `--node` 指定稳定路径。`n2n-nexus doctor [claude|codex]` 检查已安装的 hook 命令和 MCP 命令里的 Node 路径与入口脚本是否仍然存在、能否运行,有问题时以退出码 1 退出。
117
120
  - `n2n-nexus uninstall <claude|codex>` 删除 `install` 写入的 hooks 和 MCP 服务,配置里的其他内容不动。
118
121
  - 支持 Linux、WSL 和 macOS。其他 MCP 客户端可以使用除直接消息以外的全部工具。
119
122
 
@@ -234,9 +237,13 @@ n2n-nexus daemon [--port 5688] [--root ~/.n2n-nexus] [--host 127.0.0.1]
234
237
  NEXUS_ENDPOINT=http://127.0.0.1:5688 n2n-nexus mcp
235
238
 
236
239
  # 为 Claude Code 或 Codex 安装 / 删除 hooks 和 MCP 服务
237
- n2n-nexus install <claude|codex> [--endpoint <url>]
240
+ # --node <path>:写进客户端配置的 Node 可执行文件路径
241
+ n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
238
242
  n2n-nexus uninstall <claude|codex>
239
243
 
244
+ # 检查已安装的 hook 和 MCP 命令是否还能运行(有问题时退出码 1)
245
+ n2n-nexus doctor [claude|codex]
246
+
240
247
  # hook 入口(由 install 写进客户端配置)
241
248
  n2n-nexus hook <claude|codex> [--endpoint <url>]
242
249
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-nexus",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Local-first MCP coordination hub from N2NS Lab for multi-AI assistant collaboration.",
5
5
  "main": "build/index.js",
6
6
  "type": "module",