n2n-nexus 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +40 -8
- package/build/cli/autostart.js +64 -0
- package/build/cli/doctor.js +187 -0
- package/build/cli/hook.js +76 -0
- package/build/cli/install.js +267 -0
- package/build/client/nexus-client.js +14 -12
- package/build/daemon/hook-contract.js +68 -0
- package/build/daemon/server.js +266 -72
- package/build/daemon/tool-outputs.js +25 -22
- package/build/daemon/tools.js +54 -49
- package/build/index.js +26 -1
- package/build/server/nexus.js +63 -72
- package/build/storage/agents.js +227 -0
- package/build/storage/index.js +16 -11
- package/build/storage/migrations.js +2 -1
- package/build/storage/projects.js +10 -31
- package/build/storage/registry.js +71 -13
- package/build/storage/sqlite.js +48 -23
- package/docs/ARCHITECTURE.md +31 -12
- package/docs/ARCHITECTURE_zh.md +32 -13
- package/docs/ASSISTANT_GUIDE.md +8 -5
- package/docs/README_zh.md +45 -8
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/build/storage/direct-messages.js +0 -89
- package/build/storage/sessions.js +0 -77
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.1] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `n2n-nexus install <claude|codex>` writes the `node` found on PATH when it is the running binary, instead of `process.execPath`, the resolved version directory (nvm, Homebrew Cellar, fnm, ...) that disappears when Node is upgraded. `--node <path>` chooses the path explicitly; it must exist, run and satisfy `engines.node`. `install` prints the path it wrote and warns when it is in a version directory.
|
|
10
|
+
- `n2n-nexus doctor [claude|codex]`: checks that the Node path and the entry script in the installed hook and MCP commands exist and run, and says how to fix them.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Daemon responses are decoded as streaming UTF-8; a multi-byte character split across two chunks no longer turns into U+FFFD.
|
|
15
|
+
- `rename_project` renames the directory, the manifests and the registry entry as one unit and rolls everything back when a step fails.
|
|
16
|
+
- `host_delete_project` fails, and keeps the registry entry, when the project files cannot be removed.
|
|
17
|
+
- `upload_project_asset` checks that the project exists inside the write lock, so an upload racing a delete no longer recreates the deleted project. It returns the name the asset is saved under, and a file name that sanitizes to `.` is rejected.
|
|
18
|
+
- `listen({ on: false })` ends a Stop hook that is already waiting instead of letting it run to the timeout.
|
|
19
|
+
|
|
20
|
+
### Upgrade notes
|
|
21
|
+
|
|
22
|
+
- Run `n2n-nexus install claude` and/or `n2n-nexus install codex` again so the hooks and the MCP command use a stable Node path.
|
|
23
|
+
|
|
24
|
+
## [0.8.0] - 2026-10-21
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Every Claude Code and Codex conversation is its own session, `claude:<id>` or `codex:<id>`, using the ID the client assigns. Resuming a conversation, restarting the adapter and sub-agents keep it.
|
|
29
|
+
- Direct-message tools `peers`, `send`, `read`, `reply`, `listen` and `set_profile`. Tool calls name their conversation through the MCP request `_meta` (Claude Code `claudecode/toolUseId`, bound by a PreToolUse hook; Codex `sessionId`), never through arguments.
|
|
30
|
+
- `n2n-nexus hook <claude|codex>` and `POST /api/hook`: client hooks keep each conversation's status (working, idle, listening, offline) and announce new messages when the user sends a prompt, after each tool call and once before the turn ends. Notifications carry only the sender and first line; the text arrives through `read`, as tool output.
|
|
31
|
+
- `listen`: a conversation that ends its turn keeps waiting up to 590 seconds and continues when a message arrives. `send(notifyWhenIdle)` gives one notice when the recipient next ends its turn or goes offline. Unread messages expire after 24 hours and the sender is told.
|
|
32
|
+
- `n2n-nexus install <claude|codex> [--endpoint <url>]` and `uninstall`: write or remove the hooks and the MCP server in the client's user configuration, merging with existing settings. Codex hooks must be trusted once with `/hooks`.
|
|
33
|
+
- The adapter and the hooks start a daemon in the background when nothing listens at a local endpoint (`127.0.0.1` or `localhost`); its log is `~/.n2n-nexus/daemon.log`.
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
- `list_sessions`, `update_session`, `read_inbox`, `get_message` and `acknowledge_message`, the `to`, `replyTo` and `ttlMs` parameters of `send_message`, `POST /api/sessions/register` and `POST /api/sessions/disconnect`, and the adapter's random session ID and heartbeats. `send_message` now posts only to meetings and global chat.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- Two daemons starting together on a fresh storage root no longer crash the second one in the strategy migration; it exits on `EADDRINUSE`.
|
|
42
|
+
|
|
43
|
+
### Upgrade notes
|
|
44
|
+
|
|
45
|
+
- Run `n2n-nexus install claude` and/or `n2n-nexus install codex` after upgrading; direct messages need the hooks. Other MCP clients keep every tool except direct messages.
|
|
46
|
+
- Sessions and direct messages from 0.7.0 are not migrated; their tables are no longer read.
|
|
47
|
+
|
|
5
48
|
## [0.7.0] - 2026-10-20
|
|
6
49
|
|
|
7
50
|
### Added
|
package/README.md
CHANGED
|
@@ -61,7 +61,8 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
- **Daemon is the source of truth**: start it once and keep it running.
|
|
64
|
-
- **MCP adapters
|
|
64
|
+
- **MCP adapters are stateless**: each IDE starts an adapter, which forwards tool calls to the daemon.
|
|
65
|
+
- **Every Claude Code and Codex conversation has its own identity**: client hooks register the conversation with the daemon and notify it of new direct messages; each tool call names its conversation through the request metadata the client attaches, never through model-supplied arguments.
|
|
65
66
|
- **Cross-environment by design**: point `NEXUS_ENDPOINT` at the same daemon from different machines or shells. The daemon binds to localhost by default; use `--host 0.0.0.0` only on a trusted network.
|
|
66
67
|
|
|
67
68
|
## 🚀 Quick start
|
|
@@ -90,7 +91,27 @@ npx n2n-nexus daemon --port 5688
|
|
|
90
91
|
}
|
|
91
92
|
```
|
|
92
93
|
|
|
93
|
-
The adapter can start before the daemon. It loads the daemon tool list once the daemon becomes reachable.
|
|
94
|
+
The adapter can start before the daemon. It loads the daemon tool list once the daemon becomes reachable. When nothing listens at a local endpoint (`127.0.0.1` or `localhost`), the adapter and the hooks start a daemon in the background (log: `~/.n2n-nexus/daemon.log`).
|
|
95
|
+
|
|
96
|
+
### 3. Direct messages between Claude Code and Codex conversations
|
|
97
|
+
|
|
98
|
+
Install n2n-nexus globally, then let it write the hooks and the MCP server into the client's user configuration:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm i -g n2n-nexus
|
|
102
|
+
n2n-nexus install claude # ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR) + claude mcp add --scope user
|
|
103
|
+
n2n-nexus install codex # ~/.codex/hooks.json (or $CODEX_HOME) + codex mcp add
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Codex runs new hooks only after you trust them: open Codex and run `/hooks` once.
|
|
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.
|
|
111
|
+
- `n2n-nexus uninstall <claude|codex>` removes the hooks and the MCP server that `install` added and leaves the rest of the configuration alone.
|
|
112
|
+
- Supported on Linux, WSL and macOS. Other MCP clients can use every tool except direct messages.
|
|
113
|
+
|
|
114
|
+
Each conversation then appears in `peers`. A new direct message is announced to the recipient automatically: when the user sends a prompt, after each tool call, and once before the turn ends. The recipient reads the text with `read`. With `listen` on, a conversation that finished its turn keeps waiting for up to about 10 minutes and continues when a message arrives; while it waits, input you type is queued until you press Esc.
|
|
94
115
|
|
|
95
116
|
### Cross-environment endpoint examples
|
|
96
117
|
|
|
@@ -103,20 +124,20 @@ The adapter can start before the daemon. It loads the daemon tool list once the
|
|
|
103
124
|
|
|
104
125
|
## 🛠️ Toolset
|
|
105
126
|
|
|
106
|
-
The daemon exposes
|
|
127
|
+
The daemon exposes 32 tools, grouped by purpose:
|
|
107
128
|
|
|
108
129
|
| Group | Tools | Purpose |
|
|
109
130
|
| --- | --- | --- |
|
|
110
131
|
| **Topology** | `get_global_topology` | Read the project list or a detailed per-project subgraph. |
|
|
111
132
|
| **Projects & assets** | `search_projects`, `get_project`, `update_project`, `rename_project`, `sync_project_assets`, `upload_project_asset` | Find, read, update, and rename projects; sync manifests and internal docs; upload binary assets. |
|
|
112
133
|
| **Global docs** | `list_global_docs`, `get_global_doc`, `sync_global_doc`, `get_global_strategy`, `update_global_strategy` | List, read, create and update shared global documents and the master strategy document. |
|
|
113
|
-
| **Messaging** | `send_message`, `read_messages`, `list_events` |
|
|
114
|
-
| **
|
|
134
|
+
| **Messaging** | `send_message`, `read_messages`, `list_events` | Post meeting or global messages; read them and the activity log. |
|
|
135
|
+
| **Direct messages** | `peers`, `send`, `read`, `reply`, `listen`, `set_profile` | Find other Claude Code / Codex conversations, message them, read and answer what arrives, wait for messages, and name yourself. |
|
|
115
136
|
| **Meetings** | `list_meetings`, `start_meeting`, `end_meeting`, `archive_meeting`, `reopen_meeting` | List, open, close, archive, and reopen meeting sessions. |
|
|
116
137
|
| **Tasks** | `create_task`, `get_task`, `list_tasks`, `cancel_task` | Create and track tasks that assistants manage themselves. |
|
|
117
138
|
| **Maintenance** | `host_maintenance`, `host_delete_project` | Prune or clear the global chat stream; delete a project. |
|
|
118
139
|
|
|
119
|
-
Direct
|
|
140
|
+
Direct messages need the hooks from `n2n-nexus install`. Notifications carry only the sender and the first line; the text arrives through `read`, as tool output, so a message from another AI session never reaches the model as a system instruction. Unread messages expire after 24 hours and the sender is told. See the [assistant workflow](docs/ASSISTANT_GUIDE.md#direct-conversations).
|
|
120
141
|
|
|
121
142
|
See [Tools reference](./docs/TOOLS_REFERENCE.md) for parameter schemas and call examples.
|
|
122
143
|
|
|
@@ -144,7 +165,7 @@ Override with `--root <path>` or `NEXUS_ROOT`.
|
|
|
144
165
|
│ ├── internal_blueprint.md
|
|
145
166
|
│ └── assets/
|
|
146
167
|
├── registry.json
|
|
147
|
-
└── nexus.db # meetings, global chat, events, tasks, read cursors
|
|
168
|
+
└── nexus.db # meetings, global chat, events, tasks, read cursors, conversations and direct messages
|
|
148
169
|
```
|
|
149
170
|
|
|
150
171
|
## 🏷️ Project ID conventions
|
|
@@ -171,13 +192,24 @@ n2n-nexus daemon [--port 5688] [--root ~/.n2n-nexus] [--host 127.0.0.1]
|
|
|
171
192
|
|
|
172
193
|
# Start MCP adapter
|
|
173
194
|
NEXUS_ENDPOINT=http://127.0.0.1:5688 n2n-nexus mcp
|
|
195
|
+
|
|
196
|
+
# Install / remove the hooks and MCP server for Claude Code or Codex
|
|
197
|
+
# --node <path>: the Node executable to write into the client configuration
|
|
198
|
+
n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]
|
|
199
|
+
n2n-nexus uninstall <claude|codex>
|
|
200
|
+
|
|
201
|
+
# Check that the installed hook and MCP commands still run (exit 1 when not)
|
|
202
|
+
n2n-nexus doctor [claude|codex]
|
|
203
|
+
|
|
204
|
+
# Hook entry point (written into the client configuration by install)
|
|
205
|
+
n2n-nexus hook <claude|codex> [--endpoint <url>]
|
|
174
206
|
```
|
|
175
207
|
|
|
176
208
|
### Environment variables
|
|
177
209
|
|
|
178
210
|
| Variable | Description | Default |
|
|
179
211
|
| --- | --- | --- |
|
|
180
|
-
| `NEXUS_ENDPOINT` | Daemon URL for MCP adapter | `http://127.0.0.1:5688` |
|
|
212
|
+
| `NEXUS_ENDPOINT` | Daemon URL for the MCP adapter and the hooks | `http://127.0.0.1:5688` |
|
|
181
213
|
| `NEXUS_ROOT` | Storage root for daemon | `~/.n2n-nexus` |
|
|
182
214
|
| `NEXUS_HOST` | Daemon bind host | `127.0.0.1` |
|
|
183
215
|
| `NEXUS_DAEMON_PORT` | Daemon port (overridden by `--port`) | `5688` |
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { NexusClient } from "../client/nexus-client.js";
|
|
7
|
+
const PROBE_INTERVAL_MS = 100;
|
|
8
|
+
const PROBE_DEADLINE_MS = 2_000;
|
|
9
|
+
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost"]);
|
|
10
|
+
/** Absolute path of the package entry script (`build/index.js` next to `build/cli/`). */
|
|
11
|
+
export function entryScript() {
|
|
12
|
+
return fileURLToPath(new URL("../index.js", import.meta.url));
|
|
13
|
+
}
|
|
14
|
+
function parseEndpoint(endpoint) {
|
|
15
|
+
return new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`);
|
|
16
|
+
}
|
|
17
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
/**
|
|
19
|
+
* Start a daemon in the background when a hook or the MCP adapter finds none
|
|
20
|
+
* listening at a local endpoint (127.0.0.1 or localhost). Resolves true once
|
|
21
|
+
* GET /api/tools answers (polled every 100 ms for up to 2 s), false when the
|
|
22
|
+
* endpoint is not local or the daemon did not come up in time. Never throws.
|
|
23
|
+
*
|
|
24
|
+
* Two callers racing here each spawn a daemon; the one that loses the port
|
|
25
|
+
* exits with EADDRINUSE and both callers see the winner answer the probe.
|
|
26
|
+
*/
|
|
27
|
+
export async function startLocalDaemon(endpoint) {
|
|
28
|
+
try {
|
|
29
|
+
const url = parseEndpoint(endpoint);
|
|
30
|
+
if (!LOCAL_HOSTS.has(url.hostname))
|
|
31
|
+
return false;
|
|
32
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
33
|
+
const logDir = path.join(os.homedir(), ".n2n-nexus");
|
|
34
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
35
|
+
const log = fs.openSync(path.join(logDir, "daemon.log"), "a");
|
|
36
|
+
try {
|
|
37
|
+
const child = spawn(process.execPath, [entryScript(), "daemon", "--port", port], {
|
|
38
|
+
detached: true,
|
|
39
|
+
stdio: ["ignore", log, log],
|
|
40
|
+
env: process.env
|
|
41
|
+
});
|
|
42
|
+
child.on("error", () => { });
|
|
43
|
+
child.unref();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
fs.closeSync(log);
|
|
47
|
+
}
|
|
48
|
+
const client = new NexusClient({ endpoint: url.href, timeoutMs: 500 });
|
|
49
|
+
const deadline = Date.now() + PROBE_DEADLINE_MS;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
try {
|
|
52
|
+
await client.fetchTools();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
await sleep(PROBE_INTERVAL_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n2n-nexus doctor [claude|codex]`: check that the Node path and the entry
|
|
3
|
+
* script in the hook commands and in the MCP server command that `install`
|
|
4
|
+
* wrote still exist and run, and say how to fix what does not. Without a
|
|
5
|
+
* client both are checked; a client with nothing installed is only reported
|
|
6
|
+
* when it was named. Exit code 1 when a check failed.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
|
|
13
|
+
import { pkg } from "../config/index.js";
|
|
14
|
+
import { InstallError, VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
|
|
15
|
+
const ok = (text) => ({ ok: true, text });
|
|
16
|
+
const bad = (text) => ({ ok: false, text });
|
|
17
|
+
const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
|
|
18
|
+
/** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
|
|
19
|
+
export function parseHookCommand(command) {
|
|
20
|
+
const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
|
|
21
|
+
return m ? { node: m[1], entry: m[2] } : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function checkNode(node) {
|
|
24
|
+
if (!fs.existsSync(node))
|
|
25
|
+
return bad("missing");
|
|
26
|
+
try {
|
|
27
|
+
fs.accessSync(node, fs.constants.X_OK);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return bad("not executable");
|
|
31
|
+
}
|
|
32
|
+
const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
33
|
+
const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
|
|
34
|
+
if (!version)
|
|
35
|
+
return bad("does not run");
|
|
36
|
+
const text = probe.stdout.trim();
|
|
37
|
+
if (!satisfiesEngines(text))
|
|
38
|
+
return bad(`${text} does not satisfy ${pkg.engines.node}`);
|
|
39
|
+
return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
|
|
42
|
+
/** Where the client keeps the MCP server command. */
|
|
43
|
+
export function mcpConfigFile(client) {
|
|
44
|
+
if (client === "claude") {
|
|
45
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
|
|
46
|
+
}
|
|
47
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
|
|
48
|
+
}
|
|
49
|
+
/** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
|
|
50
|
+
export function readMcpCommand(client, file) {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(file, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === "ENOENT")
|
|
57
|
+
return undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (client === "claude") {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new InstallError(`${file} is not valid JSON (${error.message}).`);
|
|
67
|
+
}
|
|
68
|
+
const servers = parsed?.mcpServers;
|
|
69
|
+
const server = servers?.[MCP_SERVER_NAME];
|
|
70
|
+
if (!server || typeof server.command !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
|
|
73
|
+
}
|
|
74
|
+
// Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
|
|
77
|
+
if (start === -1)
|
|
78
|
+
return undefined;
|
|
79
|
+
let command;
|
|
80
|
+
let args = [];
|
|
81
|
+
for (const line of lines.slice(start + 1)) {
|
|
82
|
+
if (/^\s*\[/.test(line))
|
|
83
|
+
break;
|
|
84
|
+
const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
|
|
85
|
+
if (c)
|
|
86
|
+
command = JSON.parse(`"${c[1]}"`);
|
|
87
|
+
const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
|
|
88
|
+
if (a)
|
|
89
|
+
args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
|
|
90
|
+
}
|
|
91
|
+
return command === undefined ? undefined : { command, args };
|
|
92
|
+
}
|
|
93
|
+
/** Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed. */
|
|
94
|
+
function doctorClient(client) {
|
|
95
|
+
const hooksFile = configFile(client);
|
|
96
|
+
const mcpFile = mcpConfigFile(client);
|
|
97
|
+
const lines = [];
|
|
98
|
+
let failed = false;
|
|
99
|
+
let versioned = false;
|
|
100
|
+
const report = (label, check) => {
|
|
101
|
+
lines.push(` ${check.ok ? "ok " : "FAIL"} ${label}: ${check.text}`);
|
|
102
|
+
if (!check.ok)
|
|
103
|
+
failed = true;
|
|
104
|
+
if (check.text.includes(VERSIONED_NOTE))
|
|
105
|
+
versioned = true;
|
|
106
|
+
};
|
|
107
|
+
const hooks = readConfig(hooksFile).hooks ?? {};
|
|
108
|
+
const commands = new Set();
|
|
109
|
+
for (const groups of Object.values(hooks)) {
|
|
110
|
+
if (!Array.isArray(groups))
|
|
111
|
+
continue;
|
|
112
|
+
for (const group of groups) {
|
|
113
|
+
const entries = group?.hooks;
|
|
114
|
+
if (!Array.isArray(entries))
|
|
115
|
+
continue;
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (isOurs(entry, client))
|
|
118
|
+
commands.add(entry.command);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const mcp = readMcpCommand(client, mcpFile);
|
|
123
|
+
if (commands.size === 0 && !mcp)
|
|
124
|
+
return undefined;
|
|
125
|
+
lines.push(`${client}`);
|
|
126
|
+
if (commands.size === 0)
|
|
127
|
+
report(`hooks in ${hooksFile}`, bad("not installed"));
|
|
128
|
+
for (const command of commands) {
|
|
129
|
+
const parsed = parseHookCommand(command);
|
|
130
|
+
if (!parsed) {
|
|
131
|
+
report(`hook command ${command}`, bad("not in the form \"<node>\" \"<entry>\" hook ..."));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
report(`hook Node ${parsed.node}`, checkNode(parsed.node));
|
|
135
|
+
report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
|
|
136
|
+
}
|
|
137
|
+
if (!mcp) {
|
|
138
|
+
report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
|
|
142
|
+
const entry = mcp.args[0];
|
|
143
|
+
if (entry)
|
|
144
|
+
report(`MCP entry ${entry}`, checkEntry(entry));
|
|
145
|
+
else
|
|
146
|
+
report(`MCP command in ${mcpFile}`, bad("has no entry script argument"));
|
|
147
|
+
}
|
|
148
|
+
if (failed) {
|
|
149
|
+
lines.push(` fix: run "n2n-nexus install ${client}" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
|
|
150
|
+
}
|
|
151
|
+
else if (versioned) {
|
|
152
|
+
lines.push(` note: after upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.`);
|
|
153
|
+
}
|
|
154
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
155
|
+
return !failed;
|
|
156
|
+
}
|
|
157
|
+
/** Parse `[claude|codex]`, run the checks and return the exit code. */
|
|
158
|
+
export function runDoctor(argv) {
|
|
159
|
+
const client = argv[0];
|
|
160
|
+
if (client !== undefined && !isHookClient(client)) {
|
|
161
|
+
process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
let failed = false;
|
|
166
|
+
for (const c of client ? [client] : HOOK_CLIENTS) {
|
|
167
|
+
const result = doctorClient(c);
|
|
168
|
+
if (result === undefined) {
|
|
169
|
+
if (client) {
|
|
170
|
+
process.stdout.write(`${c}\n FAIL nothing installed: run "n2n-nexus install ${c}".\n`);
|
|
171
|
+
failed = true;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
process.stdout.write(`${c}\n not installed\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (!result) {
|
|
178
|
+
failed = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return failed ? 1 : 0;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
process.stderr.write(`[n2n-nexus] doctor failed: ${error.message}\n`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `n2n-nexus hook <claude|codex> [--endpoint <url>]`: forward one client hook
|
|
3
|
+
* event to the daemon and print its output. Runs on every tool call, so it
|
|
4
|
+
* loads only node built-ins (no MCP SDK, zod or SQLite), and every failure is
|
|
5
|
+
* silent: exit 0 with nothing on stdout, so the client carries on unaffected.
|
|
6
|
+
*/
|
|
7
|
+
import { HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, isHookClient } from "../daemon/hook-contract.js";
|
|
8
|
+
import { NexusClient, NexusTransportError } from "../client/nexus-client.js";
|
|
9
|
+
const DEFAULT_ENDPOINT = "http://127.0.0.1:5688";
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
async function readStdin() {
|
|
14
|
+
const chunks = [];
|
|
15
|
+
for await (const chunk of process.stdin)
|
|
16
|
+
chunks.push(chunk);
|
|
17
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
18
|
+
}
|
|
19
|
+
function argValue(argv, name) {
|
|
20
|
+
const i = argv.indexOf(name);
|
|
21
|
+
return i !== -1 ? argv[i + 1] : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** Returns the text to print on stdout ("" for nothing); throws on any failure. */
|
|
24
|
+
async function forward(argv) {
|
|
25
|
+
const client = argv[0];
|
|
26
|
+
if (!isHookClient(client))
|
|
27
|
+
throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
|
|
28
|
+
const endpoint = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || DEFAULT_ENDPOINT;
|
|
29
|
+
const stdin = JSON.parse(await readStdin());
|
|
30
|
+
if (!isPlainObject(stdin))
|
|
31
|
+
throw new Error("hook input is not a JSON object");
|
|
32
|
+
const input = {};
|
|
33
|
+
for (const field of HOOK_INPUT_FIELDS) {
|
|
34
|
+
if (Object.prototype.hasOwnProperty.call(stdin, field))
|
|
35
|
+
input[field] = stdin[field];
|
|
36
|
+
}
|
|
37
|
+
const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
|
|
38
|
+
const nexus = new NexusClient({ endpoint });
|
|
39
|
+
const send = () => nexus.hook({ client, input }, timeoutMs);
|
|
40
|
+
let response;
|
|
41
|
+
try {
|
|
42
|
+
response = await send();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof NexusTransportError && error.code === "ECONNREFUSED"))
|
|
46
|
+
throw error;
|
|
47
|
+
const { startLocalDaemon } = await import("./autostart.js");
|
|
48
|
+
if (!await startLocalDaemon(endpoint))
|
|
49
|
+
throw error;
|
|
50
|
+
response = await send();
|
|
51
|
+
}
|
|
52
|
+
if (!isPlainObject(response) || !("output" in response))
|
|
53
|
+
throw new Error("invalid response from daemon");
|
|
54
|
+
const { output } = response;
|
|
55
|
+
if (output === null)
|
|
56
|
+
return "";
|
|
57
|
+
if (!isPlainObject(output))
|
|
58
|
+
throw new Error("invalid output from daemon");
|
|
59
|
+
return JSON.stringify(output) + "\n";
|
|
60
|
+
}
|
|
61
|
+
/** Run the hook command; always resolves to exit code 0. */
|
|
62
|
+
export async function runHook(argv) {
|
|
63
|
+
let text = "";
|
|
64
|
+
try {
|
|
65
|
+
text = await forward(argv);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
process.stderr.write(`[n2n-nexus] hook: ${message.replace(/\s+/g, " ").slice(0, 300)}\n`);
|
|
70
|
+
}
|
|
71
|
+
if (text) {
|
|
72
|
+
process.stdout.on("error", () => { });
|
|
73
|
+
await new Promise((resolve) => process.stdout.write(text, () => resolve()));
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|