n2n-nexus 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@
4
4
 
5
5
  All notable changes to this project will be documented in this file.
6
6
 
7
+ ## [0.12.3] - 2026-10-29
8
+
9
+ ### Fixed
10
+
11
+ - A session whose Stop event was lost (the host was down or restarting when its turn ended) goes stale after 30 minutes like any other: the background wait no longer counts as its activity while it is working, so it stops being reported as working and messages sent to it stop waiting for a wake that never comes.
12
+ - A host bound to a name (`--host mybox`) accepts requests addressed by that name: its own health probes, the tray and `service install` no longer get 403 and report that nothing answers.
13
+ - Profile texts (`set_profile` title, note and label), the title a client reports, the host name, the WSL distribution and the working directory are stored on one line: line breaks and Unicode line separators in them can no longer forge a message's framing lines in another session's notification.
14
+ - A member that joins a meeting again after `leave_meeting` starts from the newest message, as documented, instead of being refused its first post for the messages it missed.
15
+ - A database written by an older host whose migrations fail now says to delete `nexus.db` when the CHANGELOG of the release says to reset the data directory, instead of a bare SQL error.
16
+ - The client survives an endpoint that is not an n2n-nexus host: an error status with a non-object body no longer crashes the hook or the MCP adapter, a tool list that is not a list leaves the adapter unconnected (it retries) instead of half-connected, and a tool call answered with a non-object body fails as a transport error. The discovery cache is written through a temporary file and renamed into place, so a concurrent hook never reads a partial URL.
17
+ - On Windows, upgrading through `install` restarts the background service with its data directory intact when that directory ends in a backslash and contains a space.
18
+
19
+ ### Changed
20
+
21
+ - The Windows firewall rules added by **Allow LAN access** apply to private and domain networks only, not to public ones. Rules added by an earlier version are not changed until LAN access is turned off and on again.
22
+ - On WSL, the MCP adapter's instance ID includes the distribution, since distributions on one Windows machine share its host name; two distributions on the same workspace no longer share chat and meeting read cursors. The ID changes once for WSL users, which resets those cursors (not the hooks' per-conversation ones).
23
+
24
+ ## [0.12.2] - 2026-10-29
25
+
26
+ ### Changed
27
+
28
+ - 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.
29
+ - 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.
30
+
7
31
  ## [0.12.1] - 2026-10-29
8
32
 
9
33
  ### 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");
@@ -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 { codex: program.label, ours: hooks.length, untrusted: hooks.filter((hook) => hook.trustStatus !== "trusted").map((hook) => hook.key) };
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. */
@@ -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 { MCP_PACKAGE, VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
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
- /** The Node path and the entry script of one of our hook commands, `"<node>" "<entry>" hook <client> ...`. */
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
- try {
32
- fs.accessSync(node, fs.constants.X_OK);
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
- const text = probe.stdout.trim();
42
- if (!satisfiesEngines(text))
43
- return bad(`${text} does not satisfy ${pkg.engines.node}`);
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 hooks = readConfig(hooksFile).hooks ?? {};
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 = process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
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, LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
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
- /** Returns the text to print on stdout ("" for nothing); throws on any failure. */
33
- async function forward(argv) {
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
- const configured = argValue(argv, "--endpoint") || process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
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[0];
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());
@@ -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
- export class InstallError extends Error {
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
- export function stableNodePath(env = process.env) {
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
- export function parseVersion(text) {
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
- /** Check a `--node` path: it must exist, run, and satisfy engines.node. Returns the absolute path. */
127
- export function checkNodePath(given) {
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
- throw new InstallError(`--node ${given}: not an executable file.`);
133
+ return { error: "not executable" };
134
134
  }
135
135
  if (fs.statSync(node).isDirectory())
136
- throw new InstallError(`--node ${given}: is a directory, not the node executable.`);
136
+ return { error: "directory" };
137
137
  const probe = spawnSync(node, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
138
- const version = probe.status === 0 ? parseVersion(probe.stdout) : undefined;
139
- if (!version)
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 (!satisfiesEngines(probe.stdout)) {
142
- throw new InstallError(`--node ${given}: Node ${probe.stdout.trim()} does not satisfy ${pkg.engines.node}.`);
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
- if (client === "claude") {
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
- export const INSTRUCTIONS_BEGIN = "<!-- n2n-nexus:begin (written by \"n2n-nexus install claude\", removed by \"n2n-nexus uninstall claude\") -->";
270
- export const INSTRUCTIONS_END = "<!-- n2n-nexus:end -->";
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
- export function instructionsFile() {
279
- return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "CLAUDE.md");
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 = `"${node}" "${entry}" hook ${client}${endpoint ? ` --endpoint ${endpoint}` : ""}`;
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) {
@@ -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
- 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");
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) {
@@ -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
- export function cleanTitle(value) {
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 = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
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 DISCOVERY_QUERY = "n2n-nexus/discover";
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
- export function discoveryPort() {
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 ?? [])
@@ -116,8 +120,18 @@ export async function resolveEndpoint(configured, options = {}) {
116
120
  return cached.endpoint;
117
121
  const endpoint = await discoverEndpoint() ?? LOCAL_ENDPOINT;
118
122
  try {
119
- fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
120
- fs.writeFileSync(cacheFile(), endpoint + "\n");
123
+ // Written next to the file and renamed over it: a hook reading it at the same time never sees a partial URL.
124
+ const target = cacheFile();
125
+ fs.mkdirSync(path.dirname(target), { recursive: true });
126
+ const tmp = `${target}.${process.pid}.tmp`;
127
+ fs.writeFileSync(tmp, endpoint + "\n");
128
+ try {
129
+ fs.renameSync(tmp, target);
130
+ }
131
+ catch (e) {
132
+ fs.rmSync(tmp, { force: true });
133
+ throw e;
134
+ }
121
135
  }
122
136
  catch { /* caching is best effort */ }
123
137
  return endpoint;
@@ -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 || process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
27
+ const raw = configuredEndpoint(endpoint);
27
28
  if (/^https?:\/\//i.test(raw))
28
29
  return raw;
29
30
  return `http://${raw}`;
@@ -67,7 +68,9 @@ export class NexusClient {
67
68
  }
68
69
  const status = res.statusCode ?? 0;
69
70
  if (status >= 400) {
70
- reject(new NexusToolError(parsed.error || `HTTP ${status}`, status));
71
+ // The body may be any JSON value (null, a number, ...): only an object carries an error text.
72
+ const error = typeof parsed === "object" && parsed !== null ? parsed.error : undefined;
73
+ reject(new NexusToolError(typeof error === "string" && error ? error : `HTTP ${status}`, status));
71
74
  }
72
75
  else {
73
76
  resolve(parsed);
@@ -87,6 +90,8 @@ export class NexusClient {
87
90
  }
88
91
  async fetchTools() {
89
92
  const res = await this.request("GET", "/api/tools");
93
+ if (typeof res !== "object" || res === null || !Array.isArray(res.tools))
94
+ throw new NexusTransportError("Invalid tools response from n2n-nexus-host");
90
95
  return res.tools;
91
96
  }
92
97
  /** Forward a client hook event; `timeoutMs` overrides the client timeout (a listening Stop waits long). */
@@ -95,6 +100,8 @@ export class NexusClient {
95
100
  }
96
101
  async callTool(name, args, instanceId, caller) {
97
102
  const res = await this.request("POST", "/api/tools/call", { tool: name, args: args || {}, ...(instanceId ? { instanceId } : {}), ...(caller ? { caller } : {}) });
103
+ if (!res || typeof res !== "object")
104
+ throw new NexusTransportError("Invalid tool call response from n2n-nexus-host");
98
105
  if (!res.ok)
99
106
  throw new NexusToolError(res.error || "Tool call failed", 200);
100
107
  return res.result;
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
- export const NO_HOST_HINT = `Install n2n-nexus-host (${HOST_RELEASES_URL}) and start it: run the tray app, or "n2n-nexus-host serve".`;
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
- export 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>\").";
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
- https://github.com/n2ns/n2n-nexus/releases
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 http://127.0.0.1:5688),
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: http://127.0.0.1:5688), or "discover"
41
- NEXUS_DISCOVERY_PORT UDP port of LAN discovery (default: 5688)
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);
@@ -6,6 +6,8 @@ import os from "node:os";
6
6
  * survive an IDE restart: derive it from the machine, the workspace the IDE
7
7
  * launched the adapter in and the IDE itself (two IDEs open on the same
8
8
  * workspace must not share cursors), unless the user pins one explicitly.
9
+ * WSL distributions on one Windows machine share its host name, so the
10
+ * distribution goes into the workspace hash.
9
11
  */
10
12
  export function resolveInstanceId(env = process.env, argv = process.argv, cwd = process.cwd(), clientName) {
11
13
  if (env.NEXUS_INSTANCE_ID)
@@ -13,7 +15,7 @@ export function resolveInstanceId(env = process.env, argv = process.argv, cwd =
13
15
  const idx = argv.indexOf("--id");
14
16
  if (idx !== -1 && argv[idx + 1])
15
17
  return argv[idx + 1];
16
- const digest = createHash("sha1").update(cwd).digest("hex").slice(0, 8);
18
+ const digest = createHash("sha1").update(env.WSL_DISTRO_NAME ? `${env.WSL_DISTRO_NAME}\n${cwd}` : cwd).digest("hex").slice(0, 8);
17
19
  const client = clientName ? `-${clientName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24)}` : "";
18
20
  return `mcp-${os.hostname()}-${digest}${client}`;
19
21
  }
@@ -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 = process.env.NEXUS_ENDPOINT || LOCAL_ENDPOINT;
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;
@@ -13,6 +13,7 @@
13
13
  "codexSessionKey": "sessionId"
14
14
  },
15
15
  "mcpServerName": "n2n-nexus",
16
+ "discoveryQuery": "n2n-nexus/discover",
16
17
  "hookInputFields": [
17
18
  "hook_event_name",
18
19
  "session_id",
@@ -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
 
@@ -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` 键名、时限、上限、通知文本)、`errors.json`(接口、错误格式、状态码)。host 嵌入这些文件,客户端运行时读取 `hook.json`,`tests/conformance/` 里的一致性测试用它们检验 host。`GET /health` 返回 host 的 `contractVersion`,`n2n-nexus doctor` 拿它和客户端的版本比较。
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
 
@@ -4,6 +4,30 @@
4
4
 
5
5
  本项目的所有重大变更都将记录在此文件中。
6
6
 
7
+ ## [0.12.3] - 2026-10-29
8
+
9
+ ### 修复
10
+
11
+ - Stop 事件丢失的会话(回合结束时 host 正好关闭或重启)像其他会话一样 30 分钟后过期:后台等待在会话处于 working 时不再算作它的活动,于是它不再被报告为 working,发给它的消息也不再等待一个永远不会到来的唤醒。
12
+ - 绑定到名字的 host(`--host mybox`)接受以该名字寻址的请求:它自己的健康探测、托盘和 `service install` 不再得到 403、不再报告没有应答。
13
+ - 资料文本(`set_profile` 的 title、note、label)、客户端报告的标题、主机名、WSL 发行版名和工作目录都按单行存储:其中的换行和 Unicode 行分隔符不能再在其他会话的通知里伪造消息的框架行。
14
+ - `leave_meeting` 之后重新加入会议的成员按文档所说从最新消息开始,而不是因错过的消息被拒绝第一次发言。
15
+ - 旧版本 host 写的数据库迁移失败时,提示在该版本的 CHANGELOG 要求重置数据目录时删除 `nexus.db`,而不是只给一条 SQL 错误。
16
+ - 客户端能承受端点上不是 n2n-nexus host 的情况:错误状态加非对象响应体不再让 hook 或 MCP 适配器崩溃;工具列表不是列表时适配器保持未连接(继续重试)而不是半连接;工具调用得到非对象响应体时作为传输错误失败。发现缓存先写临时文件再改名到位,并发的 hook 不会读到半截 URL。
17
+ - Windows 上通过 `install` 升级时,数据目录以反斜杠结尾且含空格也能带着原数据目录重启后台服务。
18
+
19
+ ### 变更
20
+
21
+ - **允许局域网访问**添加的 Windows 防火墙规则只对专用和域网络生效,不含公用网络。早先版本添加的规则在关闭再开启局域网访问之前不会改变。
22
+ - WSL 上 MCP 适配器的实例 ID 包含发行版名,因为同一台 Windows 上的发行版共用主机名;同一工作区的两个发行版不再共用聊天和会议的已读游标。WSL 用户的 ID 会变一次,这些游标随之重置(hooks 按对话记录的游标不受影响)。
23
+
24
+ ## [0.12.2] - 2026-10-29
25
+
26
+ ### 变更
27
+
28
+ - 托盘图标和 README logo 更大、更简洁:网络图形几乎占满方块,底色提亮一档,去掉了灰边。
29
+ - 整理 host 和客户端的内部代码,行为不变:已读游标和会议通知标记由会议和对话共用一份实现(`host/internal/cursors`);局域网发现查询串改从契约读取(`contract/hook.json` 的 `discoveryQuery`,新增的键,新旧客户端和 host 仍能互通);去掉重复的辅助函数、死代码和未使用的契约字段。客户端测试每次运行只构建一次。
30
+
7
31
  ## [0.12.1] - 2026-10-29
8
32
 
9
33
  ### 变更
package/docs/INSTALL.md CHANGED
@@ -114,7 +114,7 @@ The adapter can start before the host. It loads the host's tool list once the ho
114
114
  By default the host listens on `127.0.0.1` only. To make it the host for other machines on a trusted network:
115
115
 
116
116
  1. On the host machine, turn LAN access on:
117
- - Tray: check **Allow LAN access**. The tray writes `"host": "0.0.0.0"` to `settings.json` and restarts the host. On Windows it adds inbound firewall rules for the TCP port and the UDP discovery port (5688 and 5688 by default) after a UAC prompt; on macOS and Linux it tells you which ports to allow in your firewall.
117
+ - Tray: check **Allow LAN access**. The tray writes `"host": "0.0.0.0"` to `settings.json` and restarts the host. On Windows it adds inbound firewall rules for the TCP port and the UDP discovery port (5688 and 5688 by default) on private and domain networks, not public ones, after a UAC prompt; on macOS and Linux it tells you which ports to allow in your firewall.
118
118
  - Service: `n2n-nexus-host service install --host 0.0.0.0` writes the address to `settings.json`; allow TCP 5688 and UDP 5688 in the firewall yourself.
119
119
  2. On the other machines, install the client with `--endpoint discover` (for example `n2n-nexus install claude --endpoint discover`), or with the host's address.
120
120
 
@@ -114,7 +114,7 @@ Claude Code 不需要这一步:`install claude` 写入 `settings.json` 后,h
114
114
  host 默认只监听 `127.0.0.1`。要让它成为可信网络里其他机器的 host:
115
115
 
116
116
  1. 在 host 所在的机器上打开局域网访问:
117
- - 托盘:勾选 **允许局域网访问**。托盘把 `"host": "0.0.0.0"` 写进 `settings.json` 并重启 host。Windows 上会在 UAC 确认后添加 TCP 端口和 UDP 发现端口(默认都是 5688)的入站防火墙规则;macOS 和 Linux 上会提示需要在防火墙放行的端口。
117
+ - 托盘:勾选 **允许局域网访问**。托盘把 `"host": "0.0.0.0"` 写进 `settings.json` 并重启 host。Windows 上会在 UAC 确认后添加 TCP 端口和 UDP 发现端口(默认都是 5688)的入站防火墙规则,只对专用和域网络生效,不含公用网络;macOS 和 Linux 上会提示需要在防火墙放行的端口。
118
118
  - 服务:`n2n-nexus-host service install --host 0.0.0.0` 把地址写进 `settings.json`;防火墙里的 TCP 5688 和 UDP 5688 需要自己放行。
119
119
  2. 在其他机器上用 `--endpoint discover` 安装客户端(例如 `n2n-nexus install claude --endpoint discover`),或者直接写 host 的地址。
120
120
 
@@ -12,7 +12,7 @@ Parameter tables below are derived from `contract/tools.json`, the JSON Schema t
12
12
  - Calls are rate limited per conversation, per instance without the hooks (default 20 per second, burst 60; `rateLimit` and `rateBurst` in the host's `settings.json`). Over the limit a call fails with "Rate limit exceeded ... retry after Ns"; retry after that delay.
13
13
  - Every tool has a display `title` (for example "Delete Project") for client UIs.
14
14
  - Every tool carries MCP annotations: `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint: false` (all tools act only on the host). Per the MCP definition a tool is non-destructive only when its updates are purely additive. Read tools are marked read-only. `start_meeting`, `join_meeting`, `create_task`, `send_message`, `send` and `reply` only add data and are non-destructive (`join_meeting` is also idempotent); `update_task` is marked non-destructive and idempotent too. `listen`, `set_profile`, `sync_project_assets`, `update_project`, `upload_project_asset`, `rename_project`, `sync_global_doc`, `update_global_strategy`, `end_meeting`, `archive_meeting`, `reopen_meeting`, `leave_meeting`, `stance`, `cancel_task`, `host_maintenance` and `host_delete_project` rewrite or remove existing state and are marked destructive, so clients can ask for confirmation. `read_messages` and `read` are not read-only because they advance the caller's own unread state, but they are non-destructive since no shared data changes.
15
- - Every adapter has an instance ID (`NEXUS_INSTANCE_ID`, `--id`, or `mcp-<hostname>-<hash of the working directory>-<IDE name>` by default, where the IDE name is the MCP client name from the request's `io.modelcontextprotocol/clientInfo` or, for 2025-era clients, from `initialize`). It stays the same across restarts of the same IDE in the same workspace while two IDEs on one workspace stay distinct. The adapter sends it as `instanceId` with every call. Several conversations of one IDE in one workspace share it, so chat and meeting tools identify the conversation instead when the n2n-nexus hooks are installed (see the next item); a client without the hooks is identified by its instance, and `read_messages` then fails without `instanceId`.
15
+ - Every adapter has an instance ID (`NEXUS_INSTANCE_ID`, `--id`, or `mcp-<hostname>-<hash of the working directory>-<IDE name>` by default, where the hash also covers the WSL distribution, since distributions on one Windows machine share its host name, and the IDE name is the MCP client name from the request's `io.modelcontextprotocol/clientInfo` or, for 2025-era clients, from `initialize`). It stays the same across restarts of the same IDE in the same workspace while two IDEs on one workspace stay distinct. The adapter sends it as `instanceId` with every call. Several conversations of one IDE in one workspace share it, so chat and meeting tools identify the conversation instead when the n2n-nexus hooks are installed (see the next item); a client without the hooks is identified by its instance, and `read_messages` then fails without `instanceId`.
16
16
  - Two kinds of id appear in results: conversation session ids, `claude:<id>` or `codex:<id>`, and adapter instance ids (`mcp-…`). The direct-message tools (`peers`, `send`, `read`, `reply`) use session ids. Chat and meetings use the calling conversation's session id when the hooks are installed, else its instance id: the sender and `from` of `send_message` / `read_messages`, the unread cursor, a meeting's `initiator` and `participants` and the `actor` of `meeting.*` events; results say which with `identity` (`session` or `instance`). A session id there can be used with `send`; an instance id cannot. If an instance has had a conversation identified within the last 30 minutes and one of its calls cannot be identified, the call fails with a retry message instead of using the instance's shared cursor. Other events and `updatedBy` of global docs keep the instance id.
17
17
  - Project IDs follow `[prefix]_[name]`; the prefixes are listed in the [Assistant guide](ASSISTANT_GUIDE.md#2-project-ids).
18
18
  - All project write tools run synchronously and return their result directly. The task tools (`create_task`, `get_task`, `list_tasks`, `update_task`, `cancel_task`) track work that assistants manage themselves.
@@ -12,7 +12,7 @@
12
12
  - 调用按对话限流,没装 hooks 时按实例(默认每秒 20 次,突发 60 次;对应 host `settings.json` 中的 `rateLimit` 和 `rateBurst`)。超出限制的调用会失败并返回 "Rate limit exceeded ... retry after Ns";等待该时长后重试。
13
13
  - 每个工具都有供客户端界面显示的 `title`(例如 "Delete Project")。
14
14
  - 每个工具都带有 MCP annotations:`readOnlyHint`、`destructiveHint`、`idempotentHint` 和 `openWorldHint: false`(所有工具都只作用于 host)。按照 MCP 的定义,只有更新完全是追加时,工具才算非破坏性。读取类工具标为只读。`start_meeting`、`join_meeting`、`create_task`、`send_message`、`send` 和 `reply` 只追加数据,是非破坏性的(`join_meeting` 也是幂等的);`update_task` 也标为非破坏性且幂等。`listen`、`set_profile`、`sync_project_assets`、`update_project`、`upload_project_asset`、`rename_project`、`sync_global_doc`、`update_global_strategy`、`end_meeting`、`archive_meeting`、`reopen_meeting`、`leave_meeting`、`stance`、`cancel_task`、`host_maintenance` 和 `host_delete_project` 会改写或删除已有状态,标为破坏性,以便客户端请求确认。`read_messages` 和 `read` 不是只读的,因为它们会推进调用方自己的未读状态;但它们是非破坏性的,因为不改变任何共享数据。
15
- - 每个适配器都有一个实例 ID(`NEXUS_INSTANCE_ID`、`--id`,默认是 `mcp-<hostname>-<工作目录的哈希>-<IDE 名>`,其中 IDE 名是 MCP 客户端名,取自请求的 `io.modelcontextprotocol/clientInfo`,2025 代的客户端则取自 `initialize`)。同一个 IDE 在同一个工作区里重启后它保持不变,而同一个工作区里的两个 IDE 仍然互相区分。适配器在每次调用中都以 `instanceId` 带上它。同一个 IDE 在同一个工作区里的多个对话共用它,所以装了 n2n-nexus hooks 时,聊天和会议工具改为识别对话本身(见下一条);没装 hooks 的客户端按实例识别,这时没有 `instanceId` 的 `read_messages` 会失败。
15
+ - 每个适配器都有一个实例 ID(`NEXUS_INSTANCE_ID`、`--id`,默认是 `mcp-<hostname>-<工作目录的哈希>-<IDE 名>`,其中哈希也包含 WSL 发行版名,因为同一台 Windows 上的发行版共用它的主机名;IDE 名是 MCP 客户端名,取自请求的 `io.modelcontextprotocol/clientInfo`,2025 代的客户端则取自 `initialize`)。同一个 IDE 在同一个工作区里重启后它保持不变,而同一个工作区里的两个 IDE 仍然互相区分。适配器在每次调用中都以 `instanceId` 带上它。同一个 IDE 在同一个工作区里的多个对话共用它,所以装了 n2n-nexus hooks 时,聊天和会议工具改为识别对话本身(见下一条);没装 hooks 的客户端按实例识别,这时没有 `instanceId` 的 `read_messages` 会失败。
16
16
  - 结果里会出现两种 ID:对话的会话 ID(`claude:<id>` 或 `codex:<id>`)和适配器实例 ID(`mcp-…`)。直接消息工具(`peers`、`send`、`read`、`reply`)使用会话 ID。聊天和会议在装了 hooks 时使用调用方对话的会话 ID,否则使用实例 ID:`send_message` / `read_messages` 的发送方和 `from`、未读游标、会议的 `initiator` 和 `participants`、`meeting.*` 事件的 `actor`;结果里的 `identity`(`session` 或 `instance`)说明是哪一种。这里的会话 ID 可以用于 `send`,实例 ID 不能。某个实例在最近 30 分钟内识别出过对话、而这次调用识别不出时,调用会失败并提示重试,而不会改用该实例共用的游标。其他事件和全局文档的 `updatedBy` 仍是实例 ID。
17
17
  - 项目 ID 采用 `[prefix]_[name]` 格式;前缀列在[助手指南](ASSISTANT_GUIDE_zh.md#2-项目-id)中。
18
18
  - 所有项目写入工具都同步执行并直接返回结果。任务工具(`create_task`、`get_task`、`list_tasks`、`update_task`、`cancel_task`)用于跟踪由助手自己管理的工作。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-nexus",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Local-first MCP coordination hub from N2NS Lab for multi-AI assistant collaboration.",
5
5
  "main": "build/index.js",
6
6
  "type": "module",