n2n-nexus 0.8.0 → 0.8.2

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,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.2] - 2026-10-22
6
+
7
+ ### Fixed
8
+
9
+ - Two daemons starting together on a fresh storage root: the loser no longer crashes with `SqliteError: database is locked` before reaching the port; database initialisation waits for the other process and the loser exits on `EADDRINUSE` as intended.
10
+ - Reads of the file store (`get_project`, `search_projects`, topology, global docs) share a read-write lock with the writes, so a read during `rename_project` no longer returns an entry with empty docs and tech stack.
11
+ - `sync_project_assets` and `update_project` write the manifest and docs first and the registry last, and restore the files they wrote when a later step fails; a failed create no longer leaves a project without a directory, and a failed update no longer mixes new and old fields.
12
+ - Tasks whose timestamps were written before 0.6.0 (`YYYY-MM-DD HH:MM:SS`) are converted to ISO 8601 on startup, so they are no longer purged up to a day early.
13
+ - `n2n-nexus uninstall` (and `install`) report a failing `mcp remove` when the server is still registered in the client's configuration, exit with code 1 and say what to do; "not registered" is still ignored.
14
+
15
+ ## [0.8.1] - 2026-10-22
16
+
17
+ ### Added
18
+
19
+ - `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.
20
+ - `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.
21
+
22
+ ### Fixed
23
+
24
+ - Daemon responses are decoded as streaming UTF-8; a multi-byte character split across two chunks no longer turns into U+FFFD.
25
+ - `rename_project` renames the directory, the manifests and the registry entry as one unit and rolls everything back when a step fails.
26
+ - `host_delete_project` fails, and keeps the registry entry, when the project files cannot be removed.
27
+ - `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.
28
+ - `listen({ on: false })` ends a Stop hook that is already waiting instead of letting it run to the timeout.
29
+
30
+ ### Upgrade notes
31
+
32
+ - Run `n2n-nexus install claude` and/or `n2n-nexus install codex` again so the hooks and the MCP command use a stable Node path.
33
+
5
34
  ## [0.8.0] - 2026-10-21
6
35
 
7
36
  ### 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,135 @@
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 { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
11
+ import { pkg } from "../config/index.js";
12
+ import { VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
13
+ import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
14
+ const ok = (text) => ({ ok: true, text });
15
+ const bad = (text) => ({ ok: false, text });
16
+ const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
17
+ /** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
18
+ export function parseHookCommand(command) {
19
+ const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
20
+ return m ? { node: m[1], entry: m[2] } : undefined;
21
+ }
22
+ export function checkNode(node) {
23
+ if (!fs.existsSync(node))
24
+ return bad("missing");
25
+ try {
26
+ fs.accessSync(node, fs.constants.X_OK);
27
+ }
28
+ catch {
29
+ return bad("not executable");
30
+ }
31
+ const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
32
+ const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
33
+ if (!version)
34
+ return bad("does not run");
35
+ const text = probe.stdout.trim();
36
+ if (!satisfiesEngines(text))
37
+ return bad(`${text} does not satisfy ${pkg.engines.node}`);
38
+ return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
39
+ }
40
+ 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) {
43
+ const hooksFile = configFile(client);
44
+ const mcpFile = mcpConfigFile(client);
45
+ const lines = [];
46
+ let failed = false;
47
+ let versioned = false;
48
+ const report = (label, check) => {
49
+ lines.push(` ${check.ok ? "ok " : "FAIL"} ${label}: ${check.text}`);
50
+ if (!check.ok)
51
+ failed = true;
52
+ if (check.text.includes(VERSIONED_NOTE))
53
+ versioned = true;
54
+ };
55
+ const hooks = readConfig(hooksFile).hooks ?? {};
56
+ const commands = new Set();
57
+ for (const groups of Object.values(hooks)) {
58
+ if (!Array.isArray(groups))
59
+ continue;
60
+ for (const group of groups) {
61
+ const entries = group?.hooks;
62
+ if (!Array.isArray(entries))
63
+ continue;
64
+ for (const entry of entries) {
65
+ if (isOurs(entry, client))
66
+ commands.add(entry.command);
67
+ }
68
+ }
69
+ }
70
+ const mcp = readMcpCommand(client, mcpFile);
71
+ if (commands.size === 0 && !mcp)
72
+ return undefined;
73
+ lines.push(`${client}`);
74
+ if (commands.size === 0)
75
+ report(`hooks in ${hooksFile}`, bad("not installed"));
76
+ for (const command of commands) {
77
+ const parsed = parseHookCommand(command);
78
+ if (!parsed) {
79
+ report(`hook command ${command}`, bad("not in the form \"<node>\" \"<entry>\" hook ..."));
80
+ continue;
81
+ }
82
+ report(`hook Node ${parsed.node}`, checkNode(parsed.node));
83
+ report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
84
+ }
85
+ if (!mcp) {
86
+ report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
87
+ }
88
+ else {
89
+ report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
90
+ const entry = mcp.args[0];
91
+ if (entry)
92
+ report(`MCP entry ${entry}`, checkEntry(entry));
93
+ else
94
+ report(`MCP command in ${mcpFile}`, bad("has no entry script argument"));
95
+ }
96
+ if (failed) {
97
+ lines.push(` fix: run "n2n-nexus install ${client}" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
98
+ }
99
+ else if (versioned) {
100
+ 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.`);
101
+ }
102
+ process.stdout.write(lines.join("\n") + "\n");
103
+ return !failed;
104
+ }
105
+ /** Parse `[claude|codex]`, run the checks and return the exit code. */
106
+ export function runDoctor(argv) {
107
+ const client = argv[0];
108
+ if (client !== undefined && !isHookClient(client)) {
109
+ process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
110
+ return 1;
111
+ }
112
+ try {
113
+ let failed = false;
114
+ for (const c of client ? [client] : HOOK_CLIENTS) {
115
+ const result = doctorClient(c);
116
+ if (result === undefined) {
117
+ if (client) {
118
+ process.stdout.write(`${c}\n FAIL nothing installed: run "n2n-nexus install ${c}".\n`);
119
+ failed = true;
120
+ }
121
+ else {
122
+ process.stdout.write(`${c}\n not installed\n`);
123
+ }
124
+ }
125
+ else if (!result) {
126
+ failed = true;
127
+ }
128
+ }
129
+ return failed ? 1 : 0;
130
+ }
131
+ catch (error) {
132
+ process.stderr.write(`[n2n-nexus] doctor failed: ${error.message}\n`);
133
+ return 1;
134
+ }
135
+ }
@@ -1,35 +1,107 @@
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";
20
+ import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
14
21
  const EVENTS = {
15
22
  claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
16
23
  codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
17
24
  };
18
- class InstallError extends Error {
25
+ export class InstallError extends Error {
19
26
  }
20
- function configFile(client) {
27
+ /** 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
+ /** 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
+ function sameFile(a, b) {
32
+ try {
33
+ return fs.realpathSync(a) === fs.realpathSync(b);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * The `node` on PATH when it is the running binary (its stable entry), else
41
+ * process.execPath. Relative PATH entries and fnm's per-shell directory are
42
+ * skipped: the commands must work from any directory and after the shell ends.
43
+ */
44
+ export function stableNodePath(env = process.env) {
45
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
46
+ if (!path.isAbsolute(dir) || PER_SHELL_NODE_DIR.test(dir))
47
+ continue;
48
+ const candidate = path.join(dir, "node");
49
+ if (sameFile(candidate, process.execPath))
50
+ return candidate;
51
+ }
52
+ return process.execPath;
53
+ }
54
+ export function parseVersion(text) {
55
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
56
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
57
+ }
58
+ const atLeast = (v, min) => v[0] !== min[0] ? v[0] > min[0] : v[1] !== min[1] ? v[1] > min[1] : v[2] >= min[2];
59
+ /** Whether `version` satisfies the package's engines.node range (`^a.b.c` and `>=a.b.c` clauses joined by `||`). */
60
+ export function satisfiesEngines(version, range = pkg.engines.node) {
61
+ const v = parseVersion(version);
62
+ if (!v)
63
+ return false;
64
+ return range.split("||").some((clause) => {
65
+ const m = /^\s*(\^|>=)\s*v?(\d+\.\d+\.\d+)\s*$/.exec(clause);
66
+ const min = m && parseVersion(m[2]);
67
+ if (!m || !min)
68
+ throw new Error(`Unsupported engines.node clause: ${clause.trim()}`);
69
+ return atLeast(v, min) && (m[1] === ">=" || v[0] === min[0]);
70
+ });
71
+ }
72
+ /** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
73
+ function checkNodePath(given) {
74
+ const node = path.resolve(given);
75
+ try {
76
+ fs.accessSync(node, fs.constants.X_OK);
77
+ }
78
+ catch {
79
+ throw new InstallError(`--node ${given}: not an executable file.`);
80
+ }
81
+ if (fs.statSync(node).isDirectory())
82
+ throw new InstallError(`--node ${given}: is a directory, not the node executable.`);
83
+ const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
84
+ const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
85
+ if (!version)
86
+ throw new InstallError(`--node ${given}: "${node} --version" did not print a Node version.`);
87
+ if (!satisfiesEngines(probe.stdout)) {
88
+ throw new InstallError(`--node ${given}: Node ${probe.stdout.trim()} does not satisfy ${pkg.engines.node}.`);
89
+ }
90
+ return node;
91
+ }
92
+ export function configFile(client) {
21
93
  if (client === "claude") {
22
94
  return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json");
23
95
  }
24
96
  return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json");
25
97
  }
26
- function isOurs(entry, client) {
98
+ export function isOurs(entry, client) {
27
99
  if (typeof entry !== "object" || entry === null)
28
100
  return false;
29
101
  const command = entry.command;
30
102
  return typeof command === "string" && command.includes("n2n-nexus") && command.includes(` hook ${client}`);
31
103
  }
32
- function readConfig(file) {
104
+ export function readConfig(file) {
33
105
  let text;
34
106
  try {
35
107
  text = fs.readFileSync(file, "utf8");
@@ -111,10 +183,44 @@ function mcpRemoveArgs(client) {
111
183
  ? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
112
184
  : ["mcp", "remove", MCP_SERVER_NAME];
113
185
  }
114
- function mcpAddArgs(client, entry, endpoint) {
186
+ function mcpAddArgs(client, node, entry, endpoint) {
115
187
  const env = endpoint ? [client === "claude" ? "-e" : "--env", `NEXUS_ENDPOINT=${endpoint}`] : [];
116
188
  const scope = client === "claude" ? ["--scope", "user"] : [];
117
- return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", process.execPath, entry, "mcp"];
189
+ return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", node, entry, "mcp"];
190
+ }
191
+ /**
192
+ * Run `<client> mcp remove`. A failure is ignored only when `<client> mcp get`
193
+ * shows the server is not registered (never installed, or already removed);
194
+ * otherwise the removal really failed and we stop before `mcp add`.
195
+ */
196
+ function removeMcpServer(client, action, hooksRemoved) {
197
+ const removeArgs = mcpRemoveArgs(client);
198
+ try {
199
+ execFileSync(client, removeArgs, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
200
+ if (action === "uninstall")
201
+ process.stdout.write(`Ran: ${client} ${removeArgs.join(" ")}\n`);
202
+ return;
203
+ }
204
+ catch (error) {
205
+ // "Not registered" is the one failure to ignore; the registration is read from the
206
+ // client's configuration file (an unreadable file counts as still registered).
207
+ let registered = true;
208
+ try {
209
+ registered = readMcpCommand(client, mcpConfigFile(client)) !== undefined;
210
+ }
211
+ catch {
212
+ // Keep registered = true: the error below tells the user what to look at.
213
+ }
214
+ if (!registered)
215
+ return;
216
+ const failure = error;
217
+ const stderr = typeof failure.stderr === "string" ? failure.stderr.trim() : "";
218
+ const detail = stderr || (typeof failure.status === "number" ? `exit code ${failure.status}` : failure.message);
219
+ const state = action === "uninstall"
220
+ ? `${hooksRemoved ? "The hooks were removed; t" : "T"}he MCP server is still registered in ${mcpConfigFile(client)}.`
221
+ : "The hooks were written; the existing MCP server registration was left unchanged.";
222
+ throw new InstallError(`"${client} ${removeArgs.join(" ")}" failed: ${detail}\n${state} Fix the error and run "n2n-nexus ${action} ${client}" again.`);
223
+ }
118
224
  }
119
225
  /** Install or uninstall; prints what it did and returns the exit code. */
120
226
  export function runInstall(options) {
@@ -128,30 +234,27 @@ export function runInstall(options) {
128
234
  if (probe.error || probe.status !== 0) {
129
235
  throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
130
236
  }
237
+ const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
131
238
  const file = configFile(client);
132
239
  const config = readConfig(file);
133
240
  const removed = removeOurEntries(config, client, file);
134
241
  if (action === "install") {
135
- const command = `"${process.execPath}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
242
+ const command = `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
136
243
  addOurEntries(config, client, command);
137
244
  }
138
245
  if (action === "install" || removed) {
139
246
  writeConfig(file, config);
140
247
  process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
141
248
  }
142
- const removeArgs = mcpRemoveArgs(client);
143
- try {
144
- execFileSync(client, removeArgs, { stdio: "ignore" });
145
- if (action === "uninstall")
146
- process.stdout.write(`Ran: ${client} ${removeArgs.join(" ")}\n`);
147
- }
148
- catch {
149
- // Not registered yet (or already removed): nothing to undo.
150
- }
151
- if (action === "install") {
152
- const addArgs = mcpAddArgs(client, entry, endpoint);
249
+ removeMcpServer(client, action, removed);
250
+ if (action === "install" && node) {
251
+ const addArgs = mcpAddArgs(client, node, entry, endpoint);
153
252
  execFileSync(client, addArgs, { stdio: ["ignore", "ignore", "inherit"] });
154
253
  process.stdout.write(`Ran: ${client} ${addArgs.join(" ")}\n`);
254
+ process.stdout.write(`Node: ${node}\n`);
255
+ if (VERSIONED_NODE_DIR.test(node)) {
256
+ 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`);
257
+ }
155
258
  if (client === "codex") {
156
259
  process.stdout.write("Next: open Codex and run /hooks to trust the n2n-nexus hooks; until then they do not run.\n");
157
260
  }
@@ -163,18 +266,29 @@ export function runInstall(options) {
163
266
  return 1;
164
267
  }
165
268
  }
166
- /** Parse `<claude|codex> [--endpoint <url>]` and run. */
269
+ /** Parse `<claude|codex> [--endpoint <url>] [--node <path>]` and run. */
167
270
  export function runInstallCommand(action, argv) {
168
271
  const client = argv[0];
169
272
  if (!isHookClient(client)) {
170
- process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>]" : ""}\n`);
273
+ process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
171
274
  return 1;
172
275
  }
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");
276
+ const option = (name, what) => {
277
+ const i = argv.indexOf(name);
278
+ if (i === -1 || action !== "install")
279
+ return undefined;
280
+ const value = argv[i + 1];
281
+ if (!value || value.startsWith("--")) {
282
+ process.stderr.write(`${name} needs ${what}\n`);
283
+ return null;
284
+ }
285
+ return value;
286
+ };
287
+ const endpoint = option("--endpoint", "a URL");
288
+ if (endpoint === null)
177
289
  return 1;
178
- }
179
- return runInstall({ action, client, endpoint });
290
+ const node = option("--node", "a path");
291
+ if (node === null)
292
+ return 1;
293
+ return runInstall({ action, client, endpoint, node });
180
294
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where Claude Code and Codex keep their MCP server registrations, and how to
3
+ * read the n2n-nexus entry: `~/.claude.json` (user scope) and
4
+ * `$CODEX_HOME/config.toml`. `claude mcp get` is not used for this because it
5
+ * looks in every scope, not only the user scope that install writes to, and
6
+ * it starts the server for a health check.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { MCP_SERVER_NAME } from "../daemon/hook-contract.js";
12
+ /** Where the client keeps the MCP server command. */
13
+ export function mcpConfigFile(client) {
14
+ if (client === "claude") {
15
+ return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
16
+ }
17
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
18
+ }
19
+ /** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
20
+ export function readMcpCommand(client, file) {
21
+ let text;
22
+ try {
23
+ text = fs.readFileSync(file, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT")
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ if (client === "claude") {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ }
35
+ catch (error) {
36
+ throw new Error(`${file} is not valid JSON (${error.message}).`, { cause: error });
37
+ }
38
+ const servers = parsed?.mcpServers;
39
+ const server = servers?.[MCP_SERVER_NAME];
40
+ if (!server || typeof server.command !== "string")
41
+ return undefined;
42
+ return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
43
+ }
44
+ // Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
45
+ const lines = text.split("\n");
46
+ const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
47
+ if (start === -1)
48
+ return undefined;
49
+ let command;
50
+ let args = [];
51
+ for (const line of lines.slice(start + 1)) {
52
+ if (/^\s*\[/.test(line))
53
+ 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]}"`));
60
+ }
61
+ return command === undefined ? undefined : { command, args };
62
+ }
@@ -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();
@@ -1,13 +1,14 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
- import { AsyncMutex } from "../utils/async-mutex.js";
3
+ import { AsyncRWLock } from "../utils/async-rwlock.js";
4
4
  import { FILE_ENCODING } from "../constants.js";
5
5
  /**
6
- * Serializes every file-store write inside the daemon. HTTP requests are
7
- * handled concurrently, so read-modify-write sequences on registry.json,
8
- * manifests and the docs index must be queued behind one lock.
6
+ * Guards the file store inside the daemon. HTTP requests are handled
7
+ * concurrently: writes take it exclusively, so read-modify-write sequences on
8
+ * registry.json, manifests and the docs index are queued; reads take it
9
+ * shared, so they never observe a multi-file write (such as a rename) halfway.
9
10
  */
10
- export const fileWriteLock = new AsyncMutex();
11
+ export const fileStoreLock = new AsyncRWLock();
11
12
  /**
12
13
  * Write to a temp file in the same directory, then rename over the target so
13
14
  * readers never observe a partially written file.
@@ -40,3 +41,48 @@ export function toDict(entries = []) {
40
41
  export function writeJsonAtomic(filePath, value) {
41
42
  return writeFileAtomic(filePath, JSON.stringify(value, null, 2));
42
43
  }
44
+ export async function snapshotFile(filePath) {
45
+ const dir = path.dirname(filePath);
46
+ let createsDir = false;
47
+ try {
48
+ await fs.access(dir);
49
+ }
50
+ catch (error) {
51
+ if (error.code !== "ENOENT")
52
+ throw error;
53
+ createsDir = true;
54
+ }
55
+ let original = null;
56
+ if (!createsDir) {
57
+ try {
58
+ original = await fs.readFile(filePath);
59
+ }
60
+ catch (error) {
61
+ if (error.code !== "ENOENT")
62
+ throw error;
63
+ }
64
+ }
65
+ return { path: filePath, original, createsDir };
66
+ }
67
+ /**
68
+ * Puts a file back as snapshotFile recorded it: original bytes rewritten,
69
+ * a file that did not exist removed, and a directory the write created
70
+ * removed with everything in it. Best effort: an undo failure is swallowed
71
+ * so the caller can rethrow the error that caused the rollback.
72
+ */
73
+ export async function restoreFile(snapshot) {
74
+ try {
75
+ if (snapshot.createsDir) {
76
+ await fs.rm(path.dirname(snapshot.path), { recursive: true, force: true });
77
+ }
78
+ else if (snapshot.original === null) {
79
+ await fs.rm(snapshot.path, { force: true });
80
+ }
81
+ else {
82
+ await writeFileAtomic(snapshot.path, snapshot.original);
83
+ }
84
+ }
85
+ catch {
86
+ // Best effort, see above.
87
+ }
88
+ }
@@ -4,7 +4,7 @@ import { NexusPaths } from "./paths.js";
4
4
  import { RegistryStorage } from "./registry.js";
5
5
  import { ProjectStorage } from "./projects.js";
6
6
  import { DocStorage } from "./docs.js";
7
- import { fileWriteLock, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
7
+ import { fileStoreLock, restoreFile, snapshotFile, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
8
8
  export const BLUEPRINT_PLACEHOLDER = "# Global Coordination Blueprint\n\nShared meeting space.";
9
9
  export class StorageManager {
10
10
  static initialized = false;
@@ -54,34 +54,73 @@ export class StorageManager {
54
54
  return false;
55
55
  }
56
56
  }
57
- // Every mutation of the file store goes through this lock; reads are
58
- // lock-free because writes are atomic (temp file + rename).
57
+ // Every mutation of the file store takes the lock exclusively and every
58
+ // public read takes it shared. Single files are written atomically (temp
59
+ // file + rename), but some writes span several files (a rename moves the
60
+ // directory before the registry switches ids), and a read between those
61
+ // steps would see a registry entry whose files are gone. Code inside
62
+ // write() or read() must call the sub-modules directly, never these locked
63
+ // StorageManager methods: the lock is not reentrant (a read inside a read
64
+ // deadlocks as soon as a writer is queued between them).
59
65
  static write(fn) {
60
- return fileWriteLock.withLock(fn);
66
+ return fileStoreLock.withWrite(fn);
67
+ }
68
+ static read(fn) {
69
+ return fileStoreLock.withRead(fn);
61
70
  }
62
71
  // --- Registry Methods ---
63
- static listRegistry() { return RegistryStorage.listRegistry(); }
64
- static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
72
+ static listRegistry() { return this.read(() => RegistryStorage.listRegistry()); }
73
+ static getProjectManifest(id) { return this.read(() => RegistryStorage.getProjectManifest(id)); }
65
74
  static saveProjectManifest(manifest) { return this.write(() => RegistryStorage.saveProjectManifest(manifest)); }
66
- /** Manifest and internal docs in one critical section, so a concurrent delete cannot interleave. */
75
+ /**
76
+ * Manifest and internal docs in one critical section, so a concurrent
77
+ * delete cannot interleave. Docs are written first and
78
+ * saveProjectManifest writes the registry last, so the registry only
79
+ * lists the project once all its files are in place. If anything fails,
80
+ * the docs file is put back to its original bytes (removed if it did not
81
+ * exist, with the project directory if this call created it) and the
82
+ * original error is rethrown; saveProjectManifest rolls back its own
83
+ * manifest write.
84
+ */
67
85
  static saveProject(manifest, docs) {
68
86
  return this.write(async () => {
69
- await RegistryStorage.saveProjectManifest(manifest);
70
- await ProjectStorage.saveProjectDocs(manifest.id, docs);
87
+ const snapshot = await snapshotFile(NexusPaths.projectDocPath(manifest.id));
88
+ try {
89
+ await ProjectStorage.saveProjectDocs(manifest.id, docs);
90
+ await RegistryStorage.saveProjectManifest(manifest);
91
+ }
92
+ catch (error) {
93
+ await restoreFile(snapshot);
94
+ throw error;
95
+ }
71
96
  });
72
97
  }
73
98
  static patchProjectManifest(id, patch) { return this.write(() => RegistryStorage.patchProjectManifest(id, patch)); }
74
- static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
99
+ static calculateTopology(projectId) { return this.read(() => RegistryStorage.calculateTopology(projectId)); }
75
100
  // --- Project Methods ---
76
- static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
101
+ static getProjectDocs(id) { return this.read(() => ProjectStorage.getProjectDocs(id)); }
77
102
  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)); }
103
+ /**
104
+ * The existence check runs inside the write lock: writeFileAtomic creates
105
+ * missing directories, so an upload queued behind a delete would otherwise
106
+ * bring the deleted project's directory back.
107
+ */
108
+ static saveAsset(id, fileName, content) {
109
+ return this.write(async () => {
110
+ const registry = await RegistryStorage.listRegistry();
111
+ if (!registry.projects[id])
112
+ throw new Error(`Project '${id}' not found.`);
113
+ return ProjectStorage.saveAsset(id, fileName, content);
114
+ });
115
+ }
116
+ /** Files first, index last: if the files cannot be removed, the throw leaves the registry entry in place. */
79
117
  static deleteProject(id) {
80
118
  return this.write(async () => {
81
119
  await ProjectStorage.deleteProject(id);
82
120
  await RegistryStorage.deleteProject(id);
83
121
  });
84
122
  }
123
+ /** RegistryStorage.renameProject moves the directory, manifests and registry together and rolls back on failure. */
85
124
  static renameProject(oldId, newId) {
86
125
  return this.write(async () => {
87
126
  if (!await RegistryStorage.getProjectManifest(oldId))
@@ -89,23 +128,14 @@ export class StorageManager {
89
128
  const renamed = await RegistryStorage.renameProject(oldId, newId);
90
129
  if (renamed === 0)
91
130
  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
- }
131
+ return renamed;
102
132
  });
103
133
  }
104
134
  // --- Doc Methods ---
105
- static listGlobalDocs() { return DocStorage.listDocs(); }
106
- static getGlobalDoc(docId) { return DocStorage.getDoc(docId); }
135
+ static listGlobalDocs() { return this.read(() => DocStorage.listDocs()); }
136
+ static getGlobalDoc(docId) { return this.read(() => DocStorage.getDoc(docId)); }
107
137
  static saveGlobalDoc(docId, title, content, updatedBy) { return this.write(() => DocStorage.saveDoc(docId, title, content, updatedBy)); }
108
138
  static deleteGlobalDoc(docId) { return this.write(() => DocStorage.deleteDoc(docId)); }
109
- static getGlobalBlueprint() { return DocStorage.getBlueprint(); }
139
+ static getGlobalBlueprint() { return this.read(() => DocStorage.getBlueprint()); }
110
140
  static saveGlobalBlueprint(content) { return this.write(() => DocStorage.saveBlueprint(content)); }
111
141
  }
@@ -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 { restoreFile, snapshotFile, toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
4
4
  const emptyRegistry = () => ({ projects: toDict() });
5
5
  async function readRegistry() {
6
6
  try {
@@ -91,8 +91,14 @@ export class RegistryStorage {
91
91
  static async listRegistry() {
92
92
  return readRegistry();
93
93
  }
94
+ /**
95
+ * Creates or replaces a project. Order: manifest first, registry last, so
96
+ * the registry never lists a project whose manifest was not written. If
97
+ * the registry write fails, the manifest is put back to its original
98
+ * bytes (removed if it did not exist, together with the project directory
99
+ * if this call created it) and the original error is rethrown.
100
+ */
94
101
  static async saveProjectManifest(manifest) {
95
- const projectDir = NexusPaths.projectDir(manifest.id);
96
102
  const manifestPath = NexusPaths.projectManifestPath(manifest.id);
97
103
  const registry = await readRegistry();
98
104
  registry.projects[manifest.id] = {
@@ -100,9 +106,15 @@ export class RegistryStorage {
100
106
  summary: manifest.description || "",
101
107
  lastActive: new Date().toISOString()
102
108
  };
103
- await writeRegistry(registry);
104
- await fs.mkdir(projectDir, { recursive: true });
109
+ const snapshot = await snapshotFile(manifestPath);
105
110
  await writeJsonAtomic(manifestPath, { ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() });
111
+ try {
112
+ await writeRegistry(registry);
113
+ }
114
+ catch (error) {
115
+ await restoreFile(snapshot);
116
+ throw error;
117
+ }
106
118
  }
107
119
  static async getProjectManifest(id) {
108
120
  const registry = await readRegistry();
@@ -128,6 +140,11 @@ export class RegistryStorage {
128
140
  feature_tier: m.feature_tier
129
141
  };
130
142
  }
143
+ /**
144
+ * Applies a patch to the manifest and the registry entry. Same order and
145
+ * rollback as saveProjectManifest: manifest first, registry last; if the
146
+ * registry write fails the manifest is restored to its original bytes.
147
+ */
131
148
  static async patchProjectManifest(id, patch) {
132
149
  const registry = await readRegistry();
133
150
  const existing = registry.projects[id];
@@ -139,7 +156,8 @@ export class RegistryStorage {
139
156
  name: patch.name ?? existing.name,
140
157
  lastActive: new Date().toISOString()
141
158
  };
142
- await writeRegistry(registry);
159
+ const manifestPath = NexusPaths.projectManifestPath(id);
160
+ const snapshot = await snapshotFile(manifestPath);
143
161
  const manifest = await readProjectManifest(id) || {};
144
162
  const patchedManifest = {
145
163
  id,
@@ -158,7 +176,14 @@ export class RegistryStorage {
158
176
  api_versions: patch.api_versions ?? manifest.api_versions,
159
177
  feature_tier: patch.feature_tier ?? manifest.feature_tier
160
178
  };
161
- await writeJsonAtomic(NexusPaths.projectManifestPath(id), patchedManifest);
179
+ await writeJsonAtomic(manifestPath, patchedManifest);
180
+ try {
181
+ await writeRegistry(registry);
182
+ }
183
+ catch (error) {
184
+ await restoreFile(snapshot);
185
+ throw error;
186
+ }
162
187
  }
163
188
  static async calculateTopology(projectId) {
164
189
  const registry = await readRegistry();
@@ -233,39 +258,97 @@ export class RegistryStorage {
233
258
  await writeRegistry(registry);
234
259
  return 1;
235
260
  }
261
+ /**
262
+ * Renames a project as one unit: its directory, its own manifest id, every
263
+ * relation that targets it in other manifests, and the registry key.
264
+ *
265
+ * Order: directory rename, then manifest writes, then the registry write
266
+ * last. The registry is the index readers trust, so it only switches to
267
+ * the new id once everything it points at is in place. If any step
268
+ * throws, the completed steps are undone in reverse (manifests restored to
269
+ * their original bytes, directory renamed back) and the original error is
270
+ * rethrown. Rollback is best effort: a failure while undoing is swallowed
271
+ * so the caller still sees the error that caused the rollback.
272
+ *
273
+ * Returns 0 when oldId is not registered or its directory does not exist.
274
+ */
236
275
  static async renameProject(oldId, newId) {
276
+ if (!oldId || !newId || oldId === newId)
277
+ return 0;
237
278
  const registry = await readRegistry();
238
279
  if (!registry.projects[oldId])
239
280
  return 0;
240
281
  if (registry.projects[newId]) {
241
282
  throw new Error(`Project '${newId}' already exists.`);
242
283
  }
243
- const entry = registry.projects[oldId];
244
- delete registry.projects[oldId];
245
- registry.projects[newId] = entry;
246
- await writeRegistry(registry);
284
+ const oldDir = NexusPaths.projectDir(oldId);
285
+ const newDir = NexusPaths.projectDir(newId);
286
+ try {
287
+ await fs.access(oldDir);
288
+ }
289
+ catch {
290
+ return 0;
291
+ }
292
+ // Plan every manifest rewrite before touching disk. `original` keeps
293
+ // the exact bytes for rollback; `path` is where the file lives after
294
+ // the directory rename.
295
+ const rewrites = [];
247
296
  for (const projectId of Object.keys(registry.projects)) {
248
- const manifestId = projectId === newId ? oldId : projectId;
249
- const manifest = await readProjectManifest(manifestId);
250
- if (!manifest)
297
+ let original;
298
+ try {
299
+ original = await fs.readFile(NexusPaths.projectManifestPath(projectId), "utf-8");
300
+ }
301
+ catch {
251
302
  continue;
303
+ }
304
+ let manifest;
305
+ try {
306
+ manifest = JSON.parse(original);
307
+ }
308
+ catch {
309
+ continue;
310
+ }
252
311
  let changed = false;
253
- if (projectId === newId && manifest.id !== newId) {
312
+ if (projectId === oldId) {
254
313
  manifest.id = newId;
255
314
  changed = true;
256
315
  }
257
316
  if (Array.isArray(manifest.relations)) {
258
- const relations = manifest.relations.map((relation) => {
259
- if (relation.targetId !== oldId)
317
+ manifest.relations = manifest.relations.map((relation) => {
318
+ if (relation?.targetId !== oldId)
260
319
  return relation;
261
320
  changed = true;
262
321
  return { ...relation, targetId: newId };
263
322
  });
264
- manifest.relations = relations;
265
323
  }
266
324
  if (changed) {
267
- await writeJsonAtomic(NexusPaths.projectManifestPath(manifestId), manifest);
325
+ const target = projectId === oldId ? newId : projectId;
326
+ rewrites.push({ path: NexusPaths.projectManifestPath(target), original, updated: manifest });
327
+ }
328
+ }
329
+ let dirMoved = false;
330
+ const written = [];
331
+ try {
332
+ await fs.mkdir(NexusPaths.projectsRoot, { recursive: true });
333
+ await fs.rename(oldDir, newDir);
334
+ dirMoved = true;
335
+ for (const rewrite of rewrites) {
336
+ await writeJsonAtomic(rewrite.path, rewrite.updated);
337
+ written.push(rewrite);
338
+ }
339
+ const entry = registry.projects[oldId];
340
+ delete registry.projects[oldId];
341
+ registry.projects[newId] = entry;
342
+ await writeRegistry(registry);
343
+ }
344
+ catch (error) {
345
+ for (const rewrite of written.reverse()) {
346
+ await writeFileAtomic(rewrite.path, rewrite.original).catch(() => { });
347
+ }
348
+ if (dirMoved) {
349
+ await fs.rename(newDir, oldDir).catch(() => { });
268
350
  }
351
+ throw error;
269
352
  }
270
353
  return 1;
271
354
  }
@@ -162,19 +162,64 @@ CREATE INDEX IF NOT EXISTS idx_tool_use_bindings_created ON tool_use_bindings(cr
162
162
  export function getDbPath() {
163
163
  return NexusPaths.dbFile;
164
164
  }
165
+ /** Longest a first start waits for another process that is initialising the same database. */
166
+ const INIT_BUSY_WAIT_MS = 10_000;
167
+ const INIT_RETRY_DELAY_MS = 50;
168
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
169
+ const isBusy = (error) => error?.code === "SQLITE_BUSY";
165
170
  /**
166
- * Initialize the SQLite database with WAL mode
171
+ * Open the database, switch it to WAL and create the schema. Throws SQLITE_BUSY
172
+ * (from the pragma or the schema statements) while another process is writing.
167
173
  */
168
- export function initDatabase() {
174
+ function openDatabase(dbPath) {
175
+ const candidate = new Database(dbPath);
176
+ try {
177
+ // WAL for better concurrent access. The pragma answers with the mode in effect;
178
+ // anything but "wal" (an in-memory database, a VFS without shared memory) would
179
+ // leave the daemon in rollback-journal mode, which the startup race cannot survive.
180
+ const mode = candidate.pragma("journal_mode = WAL", { simple: true });
181
+ if (mode !== "wal")
182
+ throw new Error(`WAL mode is not available for ${dbPath}: journal_mode stayed "${mode}".`);
183
+ candidate.pragma("foreign_keys = ON");
184
+ candidate.exec(SCHEMA);
185
+ return candidate;
186
+ }
187
+ catch (error) {
188
+ candidate.close();
189
+ throw error;
190
+ }
191
+ }
192
+ /**
193
+ * Initialize the SQLite database with WAL mode.
194
+ *
195
+ * Two daemons starting on a fresh root reach this together. SQLite answers the
196
+ * second one with SQLITE_BUSY at once (not through the busy timeout) while the
197
+ * first holds its write lock in rollback-journal mode, so the open is retried
198
+ * until the other process has finished; no retry starts after `busyWaitMs`.
199
+ */
200
+ export function initDatabase(options = {}) {
169
201
  if (db)
170
202
  return db;
171
203
  const dbPath = getDbPath();
172
- db = new Database(dbPath);
173
- // Enable WAL mode for better concurrent access
174
- db.pragma("journal_mode = WAL");
175
- db.pragma("foreign_keys = ON");
176
- // Initialize schema
177
- db.exec(SCHEMA);
204
+ const started = Date.now();
205
+ const deadline = started + (options.busyWaitMs ?? INIT_BUSY_WAIT_MS);
206
+ let opened;
207
+ for (;;) {
208
+ try {
209
+ opened = openDatabase(dbPath);
210
+ break;
211
+ }
212
+ catch (error) {
213
+ if (!isBusy(error))
214
+ throw error;
215
+ if (Date.now() >= deadline) {
216
+ error.message += ` (gave up after ${Date.now() - started} ms waiting for another process initialising ${dbPath})`;
217
+ throw error;
218
+ }
219
+ sleepSync(INIT_RETRY_DELAY_MS);
220
+ }
221
+ }
222
+ db = opened;
178
223
  // Migration: Add initiator column if it doesn't exist (Upgrade from v0.1.7)
179
224
  try {
180
225
  const columns = db.prepare("PRAGMA table_info(meetings)").all();
@@ -187,6 +232,21 @@ export function initDatabase() {
187
232
  catch (e) {
188
233
  console.error("[Nexus] Migration check failed:", e);
189
234
  }
235
+ // Migration: Convert task timestamps written by the pre-0.6.0 trigger and
236
+ // column defaults (datetime('now'), 'YYYY-MM-DD HH:MM:SS') to ISO 8601, so
237
+ // string comparisons against new Date().toISOString() order correctly.
238
+ try {
239
+ for (const column of ["created_at", "updated_at"]) {
240
+ const { changes } = db.prepare(`UPDATE tasks SET ${column} = strftime('%Y-%m-%dT%H:%M:%fZ', ${column})
241
+ WHERE ${column} NOT LIKE '%T%' AND strftime('%Y-%m-%dT%H:%M:%fZ', ${column}) IS NOT NULL`).run();
242
+ if (changes > 0) {
243
+ console.error(`[Nexus] Migrating database: Converted ${changes} 'tasks.${column}' value(s) to ISO 8601.`);
244
+ }
245
+ }
246
+ }
247
+ catch (e) {
248
+ console.error("[Nexus] Migration check failed:", e);
249
+ }
190
250
  // Initialize default state if not exists
191
251
  const stmt = db.prepare("INSERT OR IGNORE INTO meeting_state (key, value) VALUES (?, ?)");
192
252
  stmt.run("active_meetings", "[]");
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Readers-writer lock for async code. Any number of readers run together; a
3
+ * writer runs alone. Waiters are served in FIFO order and writers take
4
+ * priority: once a writer is queued, readers that arrive later queue behind
5
+ * it instead of joining the readers already running, so a steady stream of
6
+ * reads cannot starve a write.
7
+ */
8
+ export class AsyncRWLock {
9
+ readers = 0;
10
+ writing = false;
11
+ queue = [];
12
+ acquire(write) {
13
+ // A reader may join running readers only while nothing is queued (a queued entry is either a writer or a reader behind one).
14
+ const free = write ? !this.writing && this.readers === 0 : !this.writing && this.queue.length === 0;
15
+ if (free) {
16
+ if (write)
17
+ this.writing = true;
18
+ else
19
+ this.readers++;
20
+ return Promise.resolve();
21
+ }
22
+ return new Promise((resolve) => this.queue.push({ write, resolve }));
23
+ }
24
+ release(write) {
25
+ if (write)
26
+ this.writing = false;
27
+ else
28
+ this.readers--;
29
+ if (this.writing || this.readers > 0)
30
+ return;
31
+ // Lock is free: wake the next writer alone, or every reader up to the next queued writer.
32
+ const next = this.queue[0];
33
+ if (!next)
34
+ return;
35
+ if (next.write) {
36
+ this.queue.shift();
37
+ this.writing = true;
38
+ next.resolve();
39
+ return;
40
+ }
41
+ while (this.queue.length > 0 && !this.queue[0].write) {
42
+ this.readers++;
43
+ this.queue.shift().resolve();
44
+ }
45
+ }
46
+ async withRead(fn) {
47
+ await this.acquire(false);
48
+ try {
49
+ return await fn();
50
+ }
51
+ finally {
52
+ this.release(false);
53
+ }
54
+ }
55
+ async withWrite(fn) {
56
+ await this.acquire(true);
57
+ try {
58
+ return await fn();
59
+ }
60
+ finally {
61
+ this.release(true);
62
+ }
63
+ }
64
+ }
@@ -92,7 +92,7 @@ All data lives under the daemon's storage root (default `~/.n2n-nexus`):
92
92
 
93
93
  **SQLite WAL mode**: Only the daemon process opens the database. MCP adapters never touch it — they go through HTTP.
94
94
 
95
- **File-store writes**: HTTP requests are handled concurrently, so every mutation of `registry.json`, manifests and the docs index is queued behind one daemon-wide lock and written atomically (temp file + rename).
95
+ **File-store access**: HTTP requests are handled concurrently, so every mutation of `registry.json`, manifests and the docs index holds one daemon-wide read-write lock alone and is written atomically (temp file + rename); reads share the lock, so a rename or update is never observed half-done. A write orders its files so that `registry.json` goes last and restores what it had already written when a later step fails.
96
96
 
97
97
  **Activity log**: state changes made through the daemon (project sync/rename/delete, meeting start/end, doc sync, ...) are recorded as structured events in SQLite and read with `list_events` / `GET /api/events`; they are not mixed into chat streams.
98
98
 
@@ -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):
@@ -92,7 +92,7 @@ Stop ◀── decision: block ◀─┘ 每次结
92
92
 
93
93
  **SQLite WAL 模式**:只有 daemon 进程打开数据库。MCP 适配器不直接访问,全部经由 HTTP。
94
94
 
95
- **文件存储写入**:HTTP 请求是并发处理的,因此对 `registry.json`、manifest 和文档索引的每次修改都排在一把 daemon 级写锁后面,并以原子方式写入(临时文件 + rename)。
95
+ **文件存储访问**:HTTP 请求是并发处理的,因此对 `registry.json`、manifest 和文档索引的每次修改都独占一把 daemon 级读写锁,并以原子方式写入(临时文件 + rename);读取共享这把锁,所以不会读到改名或更新进行到一半的状态。写入时各文件按 `registry.json` 最后写的顺序进行,后一步失败就还原已写的文件。
96
96
 
97
97
  **活动日志**:经由 daemon 发生的状态变更(项目同步/重命名/删除、会议开始/结束、文档同步等)以结构化事件记录在 SQLite 中,通过 `list_events` / `GET /api/events` 读取,不混入聊天流。
98
98
 
@@ -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.2",
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",
@@ -1,36 +0,0 @@
1
- /**
2
- * Simple mutex lock for preventing concurrent file writes.
3
- * Ensures that only one write operation can happen at a time.
4
- */
5
- export class AsyncMutex {
6
- locked = false;
7
- queue = [];
8
- async acquire() {
9
- if (!this.locked) {
10
- this.locked = true;
11
- return;
12
- }
13
- return new Promise((resolve) => {
14
- this.queue.push(resolve);
15
- });
16
- }
17
- release() {
18
- if (this.queue.length > 0) {
19
- const next = this.queue.shift();
20
- if (next)
21
- next();
22
- }
23
- else {
24
- this.locked = false;
25
- }
26
- }
27
- async withLock(fn) {
28
- await this.acquire();
29
- try {
30
- return await fn();
31
- }
32
- finally {
33
- this.release();
34
- }
35
- }
36
- }