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 +29 -0
- package/README.md +8 -1
- package/build/cli/doctor.js +135 -0
- package/build/cli/install.js +141 -27
- package/build/cli/mcp-config.js +62 -0
- package/build/client/nexus-client.js +1 -0
- package/build/daemon/server.js +6 -2
- package/build/index.js +10 -1
- package/build/storage/fs-utils.js +51 -5
- package/build/storage/index.js +55 -25
- package/build/storage/projects.js +10 -31
- package/build/storage/registry.js +101 -18
- package/build/storage/sqlite.js +68 -8
- package/build/utils/async-rwlock.js +64 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/ARCHITECTURE_zh.md +3 -2
- package/docs/README_zh.md +8 -1
- package/package.json +1 -1
- package/build/utils/async-mutex.js +0 -36
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
|
-
|
|
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
|
+
}
|
package/build/cli/install.js
CHANGED
|
@@ -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
|
-
|
|
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, "--",
|
|
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 = `"${
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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)));
|
package/build/daemon/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
3
|
+
import { AsyncRWLock } from "../utils/async-rwlock.js";
|
|
4
4
|
import { FILE_ENCODING } from "../constants.js";
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* manifests and the docs index
|
|
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
|
|
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
|
+
}
|
package/build/storage/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
58
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 ===
|
|
312
|
+
if (projectId === oldId) {
|
|
254
313
|
manifest.id = newId;
|
|
255
314
|
changed = true;
|
|
256
315
|
}
|
|
257
316
|
if (Array.isArray(manifest.relations)) {
|
|
258
|
-
|
|
259
|
-
if (relation
|
|
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
|
-
|
|
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
|
}
|
package/build/storage/sqlite.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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):
|
package/docs/ARCHITECTURE_zh.md
CHANGED
|
@@ -92,7 +92,7 @@ Stop ◀── decision: block ◀─┘ 每次结
|
|
|
92
92
|
|
|
93
93
|
**SQLite WAL 模式**:只有 daemon 进程打开数据库。MCP 适配器不直接访问,全部经由 HTTP。
|
|
94
94
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
}
|