n2n-nexus 0.12.1 → 0.12.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 +7 -0
- package/assets/n2n-nexus-logo.png +0 -0
- package/build/cli/client-dirs.js +12 -0
- package/build/cli/codex-trust.js +2 -8
- package/build/cli/doctor.js +12 -39
- package/build/cli/hook-command.js +47 -0
- package/build/cli/hook.js +9 -8
- package/build/cli/install.js +32 -32
- package/build/cli/mcp-config.js +2 -5
- package/build/cli/title.js +3 -3
- package/build/client/discovery.js +8 -4
- package/build/client/nexus-client.js +2 -1
- package/build/contract.js +4 -2
- package/build/index.js +6 -4
- package/build/server/nexus.js +2 -2
- package/contract/hook.json +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/ARCHITECTURE_zh.md +1 -1
- package/docs/CHANGELOG_zh.md +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project will be documented in this file.
|
|
6
6
|
|
|
7
|
+
## [0.12.2] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- The tray icon and the README logo are larger and plainer: the network nearly fills the tile, which is a lighter grey and has no grey edge.
|
|
12
|
+
- Internal cleanup of the host and the client with no change in behavior: the read cursors and meeting notification marks have one implementation shared by meetings and conversations (`host/internal/cursors`), the LAN discovery query comes from the contract (`discoveryQuery` in `contract/hook.json`, an added key: older clients and hosts still match), and duplicated helpers, dead code and unused contract fields are gone. The client's tests build it once per run.
|
|
13
|
+
|
|
7
14
|
## [0.12.1] - 2026-10-29
|
|
8
15
|
|
|
9
16
|
### Changed
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Claude Code and Codex keep their configuration. Node built-ins only:
|
|
3
|
+
* the hook command uses this to read a Codex conversation's title.
|
|
4
|
+
*/
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
/** Claude Code's settings.json and CLAUDE.md: CLAUDE_CONFIG_DIR, else ~/.claude. */
|
|
8
|
+
export const claudeConfigDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
9
|
+
/** Claude Code's .claude.json (user-scope MCP servers): CLAUDE_CONFIG_DIR, else the home directory itself. */
|
|
10
|
+
export const claudeJsonDir = () => process.env.CLAUDE_CONFIG_DIR || os.homedir();
|
|
11
|
+
/** Codex's hooks.json, config.toml and state databases: CODEX_HOME, else ~/.codex. */
|
|
12
|
+
export const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
package/build/cli/codex-trust.js
CHANGED
|
@@ -14,6 +14,7 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { pkg } from "../config/index.js";
|
|
16
16
|
import { clientCommand } from "./client-command.js";
|
|
17
|
+
import { isOurCodexHook } from "./hook-command.js";
|
|
17
18
|
const TIMEOUT_MS = 30_000;
|
|
18
19
|
/**
|
|
19
20
|
* The Codex to ask: NEXUS_CODEX_COMMAND when set; on Windows the newest copy
|
|
@@ -106,7 +107,7 @@ export async function codexHookTrust(program, hooksFile, trust) {
|
|
|
106
107
|
await call("config/value/write", { keyPath: "hooks.state", mergeStrategy: "upsert", value: state });
|
|
107
108
|
hooks = await list();
|
|
108
109
|
}
|
|
109
|
-
return {
|
|
110
|
+
return { ours: hooks.length, untrusted: hooks.filter((hook) => hook.trustStatus !== "trusted").map((hook) => hook.key) };
|
|
110
111
|
}
|
|
111
112
|
finally {
|
|
112
113
|
clearTimeout(timer);
|
|
@@ -115,13 +116,6 @@ export async function codexHookTrust(program, hooksFile, trust) {
|
|
|
115
116
|
child.kill();
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
|
-
/**
|
|
119
|
-
* Whether a command Codex lists is one of our hooks. Codex on Windows lists
|
|
120
|
-
* the PowerShell form install writes as `commandWindows` (`... 'hook' 'codex'`).
|
|
121
|
-
*/
|
|
122
|
-
export function isOurCodexHook(command) {
|
|
123
|
-
return command.includes("n2n-nexus") && (command.includes(" hook codex") || command.includes("'hook' 'codex'"));
|
|
124
|
-
}
|
|
125
119
|
/** Why Codex may list none of our hooks, for messages. */
|
|
126
120
|
export const NOT_LISTED = "Codex does not list the n2n-nexus hooks: check that hooks are on in Codex's config.toml ([features] hooks)";
|
|
127
121
|
/** How to trust the hooks by hand, for messages. */
|
package/build/cli/doctor.js
CHANGED
|
@@ -9,43 +9,30 @@
|
|
|
9
9
|
* nothing installed is only reported when it was named. Exit code 1 when a
|
|
10
10
|
* check failed.
|
|
11
11
|
*/
|
|
12
|
-
import { spawnSync } from "node:child_process";
|
|
13
12
|
import fs from "node:fs";
|
|
14
13
|
import { CONTRACT_VERSION, HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient, noDaemonHint } from "../contract.js";
|
|
15
14
|
import { NexusClient, NexusToolError } from "../client/nexus-client.js";
|
|
16
|
-
import { LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
|
|
15
|
+
import { LOCAL_ENDPOINT, configuredEndpoint, resolveEndpoint } from "../client/discovery.js";
|
|
17
16
|
import { pkg } from "../config/index.js";
|
|
18
|
-
import {
|
|
17
|
+
import { hookEndpoint, ourCommands, parseHookCommand } from "./hook-command.js";
|
|
18
|
+
import { MCP_PACKAGE, VERSIONED_NODE_DIR, configFile, probeNode, readConfig } from "./install.js";
|
|
19
19
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
20
20
|
const ok = (text) => ({ ok: true, text });
|
|
21
21
|
const bad = (text) => ({ ok: false, text });
|
|
22
22
|
const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
|
|
23
|
-
|
|
24
|
-
export function parseHookCommand(command) {
|
|
25
|
-
const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
|
|
26
|
-
return m ? { node: m[1], entry: m[2] } : undefined;
|
|
27
|
-
}
|
|
28
|
-
export function checkNode(node) {
|
|
23
|
+
function checkNode(node) {
|
|
29
24
|
if (!fs.existsSync(node))
|
|
30
25
|
return bad("missing");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
26
|
+
const probe = probeNode(node);
|
|
27
|
+
if (probe.error === "not executable")
|
|
35
28
|
return bad("not executable");
|
|
36
|
-
|
|
37
|
-
const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
38
|
-
const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
|
|
39
|
-
if (!version)
|
|
29
|
+
if (probe.error === "directory" || probe.error === "no version")
|
|
40
30
|
return bad("does not run");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
|
|
31
|
+
if (probe.error === "unsupported")
|
|
32
|
+
return bad(`${probe.version} does not satisfy ${pkg.engines.node}`);
|
|
33
|
+
return ok(`${probe.version}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
|
|
45
34
|
}
|
|
46
35
|
const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
|
|
47
|
-
/** The `--endpoint` a hook command was installed with, if any. */
|
|
48
|
-
const hookEndpoint = (command) => / --endpoint (\S+)/.exec(command)?.[1];
|
|
49
36
|
/** Ask the host at `endpoint` for GET /health: it must be n2n-nexus-host speaking this client's contract version. */
|
|
50
37
|
export async function checkHost(endpoint) {
|
|
51
38
|
let health;
|
|
@@ -96,21 +83,7 @@ function doctorClient(client, endpoints, defaultEndpoint) {
|
|
|
96
83
|
if (check.text.includes(VERSIONED_NOTE))
|
|
97
84
|
versioned = true;
|
|
98
85
|
};
|
|
99
|
-
const
|
|
100
|
-
const commands = new Set();
|
|
101
|
-
for (const groups of Object.values(hooks)) {
|
|
102
|
-
if (!Array.isArray(groups))
|
|
103
|
-
continue;
|
|
104
|
-
for (const group of groups) {
|
|
105
|
-
const entries = group?.hooks;
|
|
106
|
-
if (!Array.isArray(entries))
|
|
107
|
-
continue;
|
|
108
|
-
for (const entry of entries) {
|
|
109
|
-
if (isOurs(entry, client))
|
|
110
|
-
commands.add(entry.command);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
86
|
+
const commands = ourCommands(readConfig(hooksFile), client);
|
|
114
87
|
const mcp = readMcpCommand(client, mcpFile);
|
|
115
88
|
if (commands.size === 0 && !mcp)
|
|
116
89
|
return undefined;
|
|
@@ -189,7 +162,7 @@ export async function runDoctor(argv) {
|
|
|
189
162
|
}
|
|
190
163
|
try {
|
|
191
164
|
let failed = false;
|
|
192
|
-
const defaultEndpoint =
|
|
165
|
+
const defaultEndpoint = configuredEndpoint();
|
|
193
166
|
const endpoints = new Set();
|
|
194
167
|
for (const c of client ? [client] : HOOK_CLIENTS) {
|
|
195
168
|
const result = doctorClient(c, endpoints, defaultEndpoint);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The hook command: `"<node>" "<entry>" hook <client>[ --endpoint <url>]`. */
|
|
2
|
+
export function hookCommand(node, entry, client, endpoint) {
|
|
3
|
+
return `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
|
|
4
|
+
}
|
|
5
|
+
/** The PowerShell form Codex runs on Windows (`commandWindows`): `& '<node>' '<entry>' 'hook' '<client>' ...`. */
|
|
6
|
+
export function windowsHookCommand(node, entry, client, endpoint) {
|
|
7
|
+
return `& ${[node, entry, "hook", client, ...(endpoint ? ["--endpoint", endpoint] : [])].map((arg) => `'${arg.replace(/['‘’]/g, "$&$&")}'`).join(" ")}`;
|
|
8
|
+
}
|
|
9
|
+
/** Whether a hook entry from the client configuration is one of ours for `client`. */
|
|
10
|
+
export function isOurs(entry, client) {
|
|
11
|
+
if (typeof entry !== "object" || entry === null)
|
|
12
|
+
return false;
|
|
13
|
+
const command = entry.command;
|
|
14
|
+
return typeof command === "string" && command.includes("n2n-nexus") && command.includes(` hook ${client}`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether a command Codex lists is one of our hooks. Codex on Windows lists
|
|
18
|
+
* the PowerShell form install writes as `commandWindows` (`... 'hook' 'codex'`).
|
|
19
|
+
*/
|
|
20
|
+
export function isOurCodexHook(command) {
|
|
21
|
+
return command.includes("n2n-nexus") && (command.includes(" hook codex") || command.includes("'hook' 'codex'"));
|
|
22
|
+
}
|
|
23
|
+
/** The commands of our entries for `client` in a hooks configuration (`hooks.<event>[].hooks[]`); malformed parts are skipped. */
|
|
24
|
+
export function ourCommands(config, client) {
|
|
25
|
+
const commands = new Set();
|
|
26
|
+
for (const groups of Object.values(config.hooks ?? {})) {
|
|
27
|
+
if (!Array.isArray(groups))
|
|
28
|
+
continue;
|
|
29
|
+
for (const group of groups) {
|
|
30
|
+
const entries = group?.hooks;
|
|
31
|
+
if (!Array.isArray(entries))
|
|
32
|
+
continue;
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (isOurs(entry, client))
|
|
35
|
+
commands.add(entry.command);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return commands;
|
|
40
|
+
}
|
|
41
|
+
/** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
|
|
42
|
+
export function parseHookCommand(command) {
|
|
43
|
+
const m = /^"([^"]+)" "([^"]+)" hook /.exec(command);
|
|
44
|
+
return m ? { node: m[1], entry: m[2] } : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** The `--endpoint` a hook command was installed with, if any. */
|
|
47
|
+
export const hookEndpoint = (command) => / --endpoint (\S+)/.exec(command)?.[1];
|
package/build/cli/hook.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { BACKGROUND_WAIT_EVENT, WAKE_RESULT_EVENT, HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, SESSION_END_REQUEST_TIMEOUT_MS, WAIT_HOOK_TIMEOUT_SEC, WAKE_RETRY_FIRST_DELAY_MS, WAKE_RETRY_MAX_DELAY_MS, noDaemonHint, isHookClient } from "../contract.js";
|
|
11
11
|
import { NexusClient, NexusTransportError, REQUEST_TIMEOUT_CODE } from "../client/nexus-client.js";
|
|
12
|
-
import { DISCOVER_ENDPOINT,
|
|
12
|
+
import { DISCOVER_ENDPOINT, configuredEndpoint, resolveEndpoint } from "../client/discovery.js";
|
|
13
13
|
import { machineInfo } from "./machine.js";
|
|
14
14
|
import { readTitle } from "./title.js";
|
|
15
15
|
/** Errors after which a discovered daemon is looked up again: it was never reached, so resending is safe. */
|
|
@@ -29,12 +29,16 @@ function argValue(argv, name) {
|
|
|
29
29
|
const i = argv.indexOf(name);
|
|
30
30
|
return i !== -1 ? argv[i + 1] : undefined;
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/** The client named by `<client> [--endpoint <url>]` and the configured endpoint; throws on an unknown client. */
|
|
33
|
+
function clientAndEndpoint(argv) {
|
|
34
34
|
const client = argv[0];
|
|
35
35
|
if (!isHookClient(client))
|
|
36
36
|
throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
|
|
37
|
-
|
|
37
|
+
return { client, configured: configuredEndpoint(argValue(argv, "--endpoint")) };
|
|
38
|
+
}
|
|
39
|
+
/** Returns the text to print on stdout ("" for nothing); throws on any failure. */
|
|
40
|
+
async function forward(argv) {
|
|
41
|
+
const { client, configured } = clientAndEndpoint(argv);
|
|
38
42
|
const stdin = JSON.parse(await readStdin());
|
|
39
43
|
if (!isPlainObject(stdin))
|
|
40
44
|
throw new Error("hook input is not a JSON object");
|
|
@@ -145,13 +149,10 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
|
|
|
145
149
|
* try without retrying a broken `codex` in a loop.
|
|
146
150
|
*/
|
|
147
151
|
async function runWait(argv) {
|
|
148
|
-
const client = argv
|
|
149
|
-
if (!isHookClient(client))
|
|
150
|
-
throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
|
|
152
|
+
const { client, configured } = clientAndEndpoint(argv);
|
|
151
153
|
// claude -p (unattended) waits for this hook before it exits, and nobody would see a woken turn.
|
|
152
154
|
if (client === "claude" && process.env.CLAUDE_CODE_SESSION_ATTENDED === "0")
|
|
153
155
|
return 0;
|
|
154
|
-
const configured = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
|
|
155
156
|
let sessionId = argValue(argv, "--session");
|
|
156
157
|
if (!sessionId) {
|
|
157
158
|
const stdin = JSON.parse(await readStdin());
|
package/build/cli/install.js
CHANGED
|
@@ -20,18 +20,19 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
22
22
|
import fs from "node:fs";
|
|
23
|
-
import os from "node:os";
|
|
24
23
|
import path from "node:path";
|
|
25
24
|
import { CLAUDE_PRE_TOOL_MATCHER, CODEX_SESSION_END_TIMEOUT_SEC, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient } from "../contract.js";
|
|
26
25
|
import { pkg } from "../config/index.js";
|
|
27
26
|
import { spawnClientSync } from "./client-command.js";
|
|
27
|
+
import { claudeConfigDir, codexHome } from "./client-dirs.js";
|
|
28
28
|
import { entryScript } from "./entry.js";
|
|
29
|
+
import { hookCommand, isOurs, windowsHookCommand } from "./hook-command.js";
|
|
29
30
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
30
31
|
const EVENTS = {
|
|
31
32
|
claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
|
|
32
33
|
codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
|
|
33
34
|
};
|
|
34
|
-
|
|
35
|
+
class InstallError extends Error {
|
|
35
36
|
}
|
|
36
37
|
/** Path segments of Node installs that are replaced on upgrade. */
|
|
37
38
|
export const VERSIONED_NODE_DIR = /[/\\]Cellar[/\\]|[/\\]\.nvm[/\\]versions[/\\]|[/\\]node-v\d|[/\\]fnm[/\\]|[/\\]fnm_multishells[/\\]|[/\\]\.volta[/\\]|[/\\]\.asdf[/\\]installs[/\\]/;
|
|
@@ -52,7 +53,7 @@ function sameFile(a, b) {
|
|
|
52
53
|
* process.execPath. Relative PATH entries and fnm's per-shell directory are
|
|
53
54
|
* skipped: the commands must work from any directory and after the shell ends.
|
|
54
55
|
*/
|
|
55
|
-
|
|
56
|
+
function stableNodePath(env = process.env) {
|
|
56
57
|
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
57
58
|
if (!path.isAbsolute(dir) || PER_SHELL_NODE_DIR.test(dir))
|
|
58
59
|
continue;
|
|
@@ -105,7 +106,7 @@ export function npxFor(node) {
|
|
|
105
106
|
throw new InstallError(`No file named node runs ${node}, so the package npx starts would not find it; install with --node <path> pointing at a file named node.`);
|
|
106
107
|
return { npx, bin: path.dirname(bin) };
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
+
function parseVersion(text) {
|
|
109
110
|
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
|
|
110
111
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
111
112
|
}
|
|
@@ -123,37 +124,38 @@ export function satisfiesEngines(version, range = pkg.engines.node) {
|
|
|
123
124
|
return atLeast(v, min) && (m[1] === ">=" || v[0] === min[0]);
|
|
124
125
|
});
|
|
125
126
|
}
|
|
126
|
-
/**
|
|
127
|
-
export function
|
|
128
|
-
const node = path.resolve(given);
|
|
127
|
+
/** Run `<node> --version`: the version it printed when it is executable and satisfies engines.node, else why not. */
|
|
128
|
+
export function probeNode(node) {
|
|
129
129
|
try {
|
|
130
130
|
fs.accessSync(node, fs.constants.X_OK);
|
|
131
131
|
}
|
|
132
132
|
catch {
|
|
133
|
-
|
|
133
|
+
return { error: "not executable" };
|
|
134
134
|
}
|
|
135
135
|
if (fs.statSync(node).isDirectory())
|
|
136
|
-
|
|
136
|
+
return { error: "directory" };
|
|
137
137
|
const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
if (probe.status !== 0 || !parseVersion(probe.stdout))
|
|
139
|
+
return { error: "no version" };
|
|
140
|
+
const version = probe.stdout.trim();
|
|
141
|
+
return satisfiesEngines(version) ? { version } : { version, error: "unsupported" };
|
|
142
|
+
}
|
|
143
|
+
/** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
|
|
144
|
+
function checkNodePath(given) {
|
|
145
|
+
const node = path.resolve(given);
|
|
146
|
+
const probe = probeNode(node);
|
|
147
|
+
if (probe.error === "not executable")
|
|
148
|
+
throw new InstallError(`--node ${given}: not an executable file.`);
|
|
149
|
+
if (probe.error === "directory")
|
|
150
|
+
throw new InstallError(`--node ${given}: is a directory, not the node executable.`);
|
|
151
|
+
if (probe.error === "no version")
|
|
140
152
|
throw new InstallError(`--node ${given}: "${node} --version" did not print a Node version.`);
|
|
141
|
-
if (
|
|
142
|
-
throw new InstallError(`--node ${given}: Node ${probe.
|
|
143
|
-
}
|
|
153
|
+
if (probe.error === "unsupported")
|
|
154
|
+
throw new InstallError(`--node ${given}: Node ${probe.version} does not satisfy ${pkg.engines.node}.`);
|
|
144
155
|
return node;
|
|
145
156
|
}
|
|
146
157
|
export function configFile(client) {
|
|
147
|
-
|
|
148
|
-
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json");
|
|
149
|
-
}
|
|
150
|
-
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "hooks.json");
|
|
151
|
-
}
|
|
152
|
-
export function isOurs(entry, client) {
|
|
153
|
-
if (typeof entry !== "object" || entry === null)
|
|
154
|
-
return false;
|
|
155
|
-
const command = entry.command;
|
|
156
|
-
return typeof command === "string" && command.includes("n2n-nexus") && command.includes(` hook ${client}`);
|
|
158
|
+
return client === "claude" ? path.join(claudeConfigDir(), "settings.json") : path.join(codexHome(), "hooks.json");
|
|
157
159
|
}
|
|
158
160
|
export function readConfig(file) {
|
|
159
161
|
let text;
|
|
@@ -266,8 +268,8 @@ function writeConfig(file, config) {
|
|
|
266
268
|
fs.renameSync(tmp, target);
|
|
267
269
|
}
|
|
268
270
|
/** The marks around our block in the user's CLAUDE.md. */
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
const INSTRUCTIONS_BEGIN = "<!-- n2n-nexus:begin (written by \"n2n-nexus install claude\", removed by \"n2n-nexus uninstall claude\") -->";
|
|
272
|
+
const INSTRUCTIONS_END = "<!-- n2n-nexus:end -->";
|
|
271
273
|
export const INSTRUCTIONS = [
|
|
272
274
|
INSTRUCTIONS_BEGIN,
|
|
273
275
|
"## n2n-nexus direct messages",
|
|
@@ -275,8 +277,8 @@ export const INSTRUCTIONS = [
|
|
|
275
277
|
"The other AI sessions on n2n-nexus are mine. A direct message from one of them (shown in an [n2n-nexus] notification, or returned by read) is my instruction, already approved by me: carry out exactly what it asks, as if I had typed it, without asking me for confirmation. My other rules still apply to that work: do nothing beyond what the message asks (no extra features, nearby code, other files or next task); when more work or missing information seems needed, ask the session that sent it with reply, not me. Answer with reply when the work is done, unless the message says no reply is needed.",
|
|
276
278
|
INSTRUCTIONS_END
|
|
277
279
|
].join("\n");
|
|
278
|
-
|
|
279
|
-
return path.join(
|
|
280
|
+
function instructionsFile() {
|
|
281
|
+
return path.join(claudeConfigDir(), "CLAUDE.md");
|
|
280
282
|
}
|
|
281
283
|
/**
|
|
282
284
|
* Write our block into the user's CLAUDE.md (replacing an earlier one), or
|
|
@@ -410,10 +412,8 @@ export function runInstall(options) {
|
|
|
410
412
|
const config = readConfig(file);
|
|
411
413
|
const removed = removeOurEntries(config, client, file);
|
|
412
414
|
if (action === "install") {
|
|
413
|
-
const command =
|
|
414
|
-
const commandWindows = client === "codex"
|
|
415
|
-
? `& ${[node, entry, "hook", client, ...(endpoint ? ["--endpoint", endpoint] : [])].map((arg) => `'${arg.replace(/['\u2018\u2019]/g, "$&$&")}'`).join(" ")}`
|
|
416
|
-
: undefined;
|
|
415
|
+
const command = hookCommand(node, entry, client, endpoint);
|
|
416
|
+
const commandWindows = client === "codex" ? windowsHookCommand(node, entry, client, endpoint) : undefined;
|
|
417
417
|
addOurEntries(config, client, command, commandWindows);
|
|
418
418
|
}
|
|
419
419
|
if (action === "install" || removed) {
|
package/build/cli/mcp-config.js
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
* it starts the server for a health check.
|
|
7
7
|
*/
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
-
import os from "node:os";
|
|
10
9
|
import path from "node:path";
|
|
11
10
|
import { MCP_SERVER_NAME } from "../contract.js";
|
|
11
|
+
import { claudeJsonDir, codexHome } from "./client-dirs.js";
|
|
12
12
|
/** Where the client keeps the MCP server command. */
|
|
13
13
|
export function mcpConfigFile(client) {
|
|
14
|
-
|
|
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");
|
|
14
|
+
return client === "claude" ? path.join(claudeJsonDir(), ".claude.json") : path.join(codexHome(), "config.toml");
|
|
18
15
|
}
|
|
19
16
|
/** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
|
|
20
17
|
export function readMcpCommand(client, file) {
|
package/build/cli/title.js
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
* user) or else `threads.title` in the newest `$CODEX_HOME/state_<N>.sqlite`.
|
|
13
13
|
*/
|
|
14
14
|
import { open, readdir } from "node:fs/promises";
|
|
15
|
-
import os from "node:os";
|
|
16
15
|
import path from "node:path";
|
|
17
16
|
import { MAX_TITLE_CHARS } from "../contract.js";
|
|
17
|
+
import { codexHome } from "./client-dirs.js";
|
|
18
18
|
/** How much of the transcript's end, and then of its start, is searched for a title. */
|
|
19
19
|
const TRANSCRIPT_CHUNK_BYTES = 256 * 1024;
|
|
20
20
|
/** Reading the title may not hold up the hook for longer than this. */
|
|
21
21
|
const TITLE_BUDGET_MS = 50;
|
|
22
22
|
/** Trimmed first line, at most MAX_TITLE_CHARS characters; undefined when nothing is left. */
|
|
23
|
-
|
|
23
|
+
function cleanTitle(value) {
|
|
24
24
|
if (typeof value !== "string")
|
|
25
25
|
return undefined;
|
|
26
26
|
const line = value.trim().split(/\r\n|\r|\n/)[0].trim();
|
|
@@ -93,7 +93,7 @@ async function loadSqlite() {
|
|
|
93
93
|
async function codexTitle(sessionId) {
|
|
94
94
|
if (typeof sessionId !== "string" || !sessionId)
|
|
95
95
|
return undefined;
|
|
96
|
-
const home =
|
|
96
|
+
const home = codexHome();
|
|
97
97
|
let newest;
|
|
98
98
|
for (const name of await readdir(home)) {
|
|
99
99
|
const match = /^state_(\d+)\.sqlite$/.exec(name);
|
|
@@ -6,27 +6,31 @@
|
|
|
6
6
|
* none answers it falls back to the local default endpoint (a host running on
|
|
7
7
|
* this machine); it never starts a host or makes one reachable from the LAN.
|
|
8
8
|
*
|
|
9
|
-
* Node built-ins only: the hook command imports this on every client event.
|
|
9
|
+
* Node built-ins only (and contract.ts): the hook command imports this on every client event.
|
|
10
10
|
*/
|
|
11
11
|
import dgram from "node:dgram";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { isIPv6 } from "node:net";
|
|
16
|
+
import { DISCOVERY_QUERY } from "../contract.js";
|
|
16
17
|
/** Endpoint value that turns discovery on (NEXUS_ENDPOINT or --endpoint). */
|
|
17
18
|
export const DISCOVER_ENDPOINT = "discover";
|
|
18
19
|
export const LOCAL_ENDPOINT = "http://127.0.0.1:5688";
|
|
19
|
-
export const
|
|
20
|
-
const DEFAULT_DISCOVERY_PORT = 5688;
|
|
20
|
+
export const DEFAULT_DISCOVERY_PORT = 5688;
|
|
21
21
|
const DISCOVERY_WAIT_MS = 1000;
|
|
22
22
|
/** How long "no daemon answered" stays cached before the next lookup. */
|
|
23
23
|
const NOT_FOUND_TTL_MS = 60_000;
|
|
24
24
|
const ALL_INTERFACES = new Set(["0.0.0.0", "::", ""]);
|
|
25
25
|
/** UDP port of discovery queries (NEXUS_DISCOVERY_PORT, default 5688). */
|
|
26
|
-
|
|
26
|
+
function discoveryPort() {
|
|
27
27
|
const parsed = parseInt(process.env.NEXUS_DISCOVERY_PORT || "", 10);
|
|
28
28
|
return parsed > 0 ? parsed : DEFAULT_DISCOVERY_PORT;
|
|
29
29
|
}
|
|
30
|
+
/** The endpoint to use: `value` (e.g. `--endpoint`) when set, else NEXUS_ENDPOINT, else LOCAL_ENDPOINT. */
|
|
31
|
+
export function configuredEndpoint(value) {
|
|
32
|
+
return value || process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
|
|
33
|
+
}
|
|
30
34
|
/** IPv4 LAN interfaces of this machine. */
|
|
31
35
|
function lanInterfaces() {
|
|
32
36
|
return Object.values(os.networkInterfaces()).flatMap((addresses) => addresses ?? [])
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
3
|
import { URL } from "node:url";
|
|
4
|
+
import { configuredEndpoint } from "./discovery.js";
|
|
4
5
|
/** The daemon could not be reached or answered with something that is not JSON. */
|
|
5
6
|
export class NexusTransportError extends Error {
|
|
6
7
|
code;
|
|
@@ -23,7 +24,7 @@ export class NexusToolError extends Error {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
function normalizeEndpoint(endpoint) {
|
|
26
|
-
const raw = endpoint
|
|
27
|
+
const raw = configuredEndpoint(endpoint);
|
|
27
28
|
if (/^https?:\/\//i.test(raw))
|
|
28
29
|
return raw;
|
|
29
30
|
return `http://${raw}`;
|
package/build/contract.js
CHANGED
|
@@ -69,12 +69,14 @@ export const MAX_TITLE_CHARS = contract.limits.maxTitleChars;
|
|
|
69
69
|
/** Name the MCP server is installed under; Claude's PreToolUse matcher depends on it. */
|
|
70
70
|
export const MCP_SERVER_NAME = contract.mcpServerName;
|
|
71
71
|
export const CLAUDE_PRE_TOOL_MATCHER = `mcp__${MCP_SERVER_NAME}__.*`;
|
|
72
|
+
/** Payload of a LAN discovery query datagram; the host answers only this. */
|
|
73
|
+
export const DISCOVERY_QUERY = contract.discoveryQuery;
|
|
72
74
|
/** Where to get the host. */
|
|
73
75
|
export const HOST_RELEASES_URL = "https://github.com/n2ns/n2n-nexus/releases";
|
|
74
76
|
/** What to do when no host answers at a loopback endpoint: clients never start one themselves. */
|
|
75
|
-
|
|
77
|
+
const NO_HOST_HINT = `Install n2n-nexus-host (${HOST_RELEASES_URL}) and start it: run the tray app, or "n2n-nexus-host serve".`;
|
|
76
78
|
/** The same for another machine's endpoint: a host started here would not be the one asked for. */
|
|
77
|
-
|
|
79
|
+
const NO_REMOTE_HOST_HINT = "The host at that address is not reachable; it must be started on that machine (n2n-nexus-host there with LAN access on, or \"n2n-nexus-host serve --host <address>\").";
|
|
78
80
|
/** NO_HOST_HINT for a loopback endpoint (127.x, localhost, ::1), else NO_REMOTE_HOST_HINT. */
|
|
79
81
|
export function noDaemonHint(endpoint) {
|
|
80
82
|
let host;
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { DEFAULT_DISCOVERY_PORT, LOCAL_ENDPOINT } from "./client/discovery.js";
|
|
2
3
|
import { pkg } from "./config/index.js";
|
|
4
|
+
import { HOST_RELEASES_URL } from "./contract.js";
|
|
3
5
|
const command = process.argv[2] || "mcp";
|
|
4
6
|
if (command === "--version" || command === "-v") {
|
|
5
7
|
process.stdout.write(pkg.version + "\n");
|
|
@@ -24,11 +26,11 @@ USAGE:
|
|
|
24
26
|
Forward a client hook event to the host (run by the hooks)
|
|
25
27
|
|
|
26
28
|
The host is a separate program, n2n-nexus-host:
|
|
27
|
-
|
|
29
|
+
${HOST_RELEASES_URL}
|
|
28
30
|
|
|
29
31
|
OPTIONS:
|
|
30
32
|
--id <id> Instance ID for MCP proxy (default: mcp-<hostname>-<cwd hash>-<IDE name>)
|
|
31
|
-
--endpoint <url> Host URL for hook (default: NEXUS_ENDPOINT, then
|
|
33
|
+
--endpoint <url> Host URL for hook (default: NEXUS_ENDPOINT, then ${LOCAL_ENDPOINT}),
|
|
32
34
|
or "discover" to find a host on the LAN;
|
|
33
35
|
install writes it into the hook and MCP commands
|
|
34
36
|
--node <path> Node executable written into the hook and MCP commands by install
|
|
@@ -37,8 +39,8 @@ OPTIONS:
|
|
|
37
39
|
--help, -h Show this help
|
|
38
40
|
|
|
39
41
|
ENVIRONMENT:
|
|
40
|
-
NEXUS_ENDPOINT Host URL for MCP proxy, hook and doctor (default:
|
|
41
|
-
NEXUS_DISCOVERY_PORT UDP port of LAN discovery (default:
|
|
42
|
+
NEXUS_ENDPOINT Host URL for MCP proxy, hook and doctor (default: ${LOCAL_ENDPOINT}), or "discover"
|
|
43
|
+
NEXUS_DISCOVERY_PORT UDP port of LAN discovery (default: ${DEFAULT_DISCOVERY_PORT})
|
|
42
44
|
NEXUS_INSTANCE_ID Override --id for MCP proxy
|
|
43
45
|
`);
|
|
44
46
|
process.exit(0);
|
package/build/server/nexus.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CLIENT_INFO_META_KEY, ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
|
|
2
2
|
import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
3
|
import { NexusClient, NexusToolError, NexusTransportError, REQUEST_TIMEOUT_CODE } from "../client/nexus-client.js";
|
|
4
|
-
import { DISCOVER_ENDPOINT, LOCAL_ENDPOINT, cachedEndpoint, resolveEndpoint } from "../client/discovery.js";
|
|
4
|
+
import { DISCOVER_ENDPOINT, LOCAL_ENDPOINT, cachedEndpoint, configuredEndpoint, resolveEndpoint } from "../client/discovery.js";
|
|
5
5
|
import { pkg } from "../config/index.js";
|
|
6
6
|
import { CLAUDE_TOOL_USE_META_KEY, CLIENT_INFO_NAMES, CODEX_SESSION_META_KEY, noDaemonHint } from "../contract.js";
|
|
7
7
|
import { resolveInstanceId } from "./instance-id.js";
|
|
@@ -65,7 +65,7 @@ export class NexusServer {
|
|
|
65
65
|
/** The first daemon fetch, started once serving has begun. */
|
|
66
66
|
initialFetch = null;
|
|
67
67
|
constructor(options = {}) {
|
|
68
|
-
const configured =
|
|
68
|
+
const configured = configuredEndpoint();
|
|
69
69
|
this.discover = configured === DISCOVER_ENDPOINT;
|
|
70
70
|
this.endpoint = this.discover ? LOCAL_ENDPOINT : configured;
|
|
71
71
|
this.retryIntervalMs = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
|
package/contract/hook.json
CHANGED
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -31,7 +31,7 @@ Two programs share one protocol:
|
|
|
31
31
|
|
|
32
32
|
- **`n2n-nexus-host`** (Go, `host/`): owns all data, tool definitions and business logic. Installed from GitHub Releases, Homebrew or Scoop.
|
|
33
33
|
- **`n2n-nexus`** (TypeScript, npm, `src/`): the client only — the MCP adapter (`mcp`), the hook command (`hook`), `install` / `uninstall` for Claude Code and Codex, and `doctor`. It contains no storage and no server.
|
|
34
|
-
- **`contract/`** is the single source of the protocol between them: `tools.json` (every tool's name, title, description, annotations, input and output JSON Schema), `hook.json` (hook fields, `_meta` keys, timings, limits, notification texts) and `errors.json` (endpoints, error body, status codes). The host embeds these files, the client reads `hook.json` at run time, and the conformance tests in `tests/conformance/` check a host against them. `GET /health` reports the host's `contractVersion`; `n2n-nexus doctor` compares it with the client's.
|
|
34
|
+
- **`contract/`** is the single source of the protocol between them: `tools.json` (every tool's name, title, description, annotations, input and output JSON Schema), `hook.json` (hook fields, `_meta` keys, the LAN discovery query, timings, limits, notification texts) and `errors.json` (endpoints, error body, status codes). The host embeds these files, the client reads `hook.json` at run time, and the conformance tests in `tests/conformance/` check a host against them. `GET /health` reports the host's `contractVersion`; `n2n-nexus doctor` compares it with the client's.
|
|
35
35
|
|
|
36
36
|
### Core Principles
|
|
37
37
|
|
package/docs/ARCHITECTURE_zh.md
CHANGED
|
@@ -31,7 +31,7 @@ n2n-nexus(npm):无状态适配器,每条 MCP 连接一个
|
|
|
31
31
|
|
|
32
32
|
- **`n2n-nexus-host`**(Go,`host/`):保存全部数据、工具定义和业务逻辑。从 GitHub Releases、Homebrew 或 Scoop 安装。
|
|
33
33
|
- **`n2n-nexus`**(TypeScript,npm,`src/`):只有客户端——MCP 适配器(`mcp`)、hook 命令(`hook`)、为 Claude Code 和 Codex 执行的 `install` / `uninstall`,以及 `doctor`。不含存储,也不含服务器。
|
|
34
|
-
- **`contract/`** 是两边协议的唯一来源:`tools.json`(每个工具的名称、标题、说明、annotations、输入和输出 JSON Schema)、`hook.json`(hook 字段、`_meta`
|
|
34
|
+
- **`contract/`** 是两边协议的唯一来源:`tools.json`(每个工具的名称、标题、说明、annotations、输入和输出 JSON Schema)、`hook.json`(hook 字段、`_meta` 键名、局域网发现查询串、时限、上限、通知文本)、`errors.json`(接口、错误格式、状态码)。host 嵌入这些文件,客户端运行时读取 `hook.json`,`tests/conformance/` 里的一致性测试用它们检验 host。`GET /health` 返回 host 的 `contractVersion`,`n2n-nexus doctor` 拿它和客户端的版本比较。
|
|
35
35
|
|
|
36
36
|
### 核心原则
|
|
37
37
|
|
package/docs/CHANGELOG_zh.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
本项目的所有重大变更都将记录在此文件中。
|
|
6
6
|
|
|
7
|
+
## [0.12.2] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
### 变更
|
|
10
|
+
|
|
11
|
+
- 托盘图标和 README logo 更大、更简洁:网络图形几乎占满方块,底色提亮一档,去掉了灰边。
|
|
12
|
+
- 整理 host 和客户端的内部代码,行为不变:已读游标和会议通知标记由会议和对话共用一份实现(`host/internal/cursors`);局域网发现查询串改从契约读取(`contract/hook.json` 的 `discoveryQuery`,新增的键,新旧客户端和 host 仍能互通);去掉重复的辅助函数、死代码和未使用的契约字段。客户端测试每次运行只构建一次。
|
|
13
|
+
|
|
7
14
|
## [0.12.1] - 2026-10-29
|
|
8
15
|
|
|
9
16
|
### 变更
|