n2n-nexus 0.12.0 → 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 CHANGED
@@ -4,6 +4,25 @@
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
+
14
+ ## [0.12.1] - 2026-10-29
15
+
16
+ ### Changed
17
+
18
+ - New tray icon, also the README logo: a small network on a charcoal tile with a grey edge, a red node in the middle joined by light blue links to four white nodes. It replaces the teal disc in the tray and the hexagon logo; the old icon and banner files are gone from `assets`. On macOS the menu bar shows the network alone as a template image, black or white to suit the menu bar.
19
+
20
+ ### Fixed
21
+
22
+ - A message the host posts because of your own call (the seat taken, the AI consensus notice, the seat's decision or veto) counts as read for you: the seat drafts the next point right after taking the seat or deciding one, and the member whose stance completed the AI consensus posts right after it, without a refusal for that message.
23
+ - Meeting news no longer repeats what `read_messages` returned in unread mode: reading a meeting also marks those messages announced, so the next hook notification shows only what came after.
24
+ - In a meeting with the user's seat, the message about a changed approver list no longer names the seat, which is in none of the approver lists.
25
+
7
26
  ## [0.12.0] - 2026-10-29
8
27
 
9
28
  Upgrade the host and the client on every machine together (contract version 4). The database is upgraded in place: keep `nexus.db`.
package/README.md CHANGED
@@ -31,6 +31,7 @@ n2n-nexus is an open-source Model Context Protocol (MCP) coordination server for
31
31
  - [Real-world example](#-real-world-example)
32
32
  - [FAQ](#-faq)
33
33
  - [Documentation](#-documentation)
34
+ - [License](#-license)
34
35
 
35
36
  ## 🔎 Looking for
36
37
 
@@ -61,8 +62,10 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
61
62
  ## 🎯 Why use it?
62
63
 
63
64
  - Coordinate multiple AI assistants working on the same project.
65
+ - Let Claude Code and Codex conversations message each other directly: a message arrives whole through the client's hooks, wakes a waiting conversation, and is answered with `reply`; each session shows the machine and environment it runs on.
66
+ - Hold meetings among assistants and keep their decisions. When you take part, your conversation holds the user's seat: you decide each draft and end the meeting, and the assistants' consensus is only reported.
64
67
  - Share project manifests, decisions, and assets across IDEs and machines.
65
- - Keep decisions, proposals, and updates in a local meeting and message log.
68
+ - Keep decisions, proposals, and updates in a local meeting and message log; old data is cleaned up after the retention you set.
66
69
  - Run the host once; connect adapters from Windows, WSL, SSH hosts, or multiple editors.
67
70
  - Avoid giving each assistant broad filesystem or backend access.
68
71
 
@@ -84,7 +87,7 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
84
87
 
85
88
  - **The host is the source of truth**: install it once and keep it running, as a tray app or as a background service.
86
89
  - **MCP adapters are stateless**: each IDE starts an adapter (`n2n-nexus mcp`), which forwards tool calls to the host.
87
- - **Every Claude Code and Codex conversation has its own identity**: client hooks register the conversation with the host 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.
90
+ - **Every Claude Code and Codex conversation has its own identity**: client hooks register the conversation with the host and deliver new direct messages and meeting messages to it; each tool call names its conversation through the request metadata the client attaches, never through model-supplied arguments.
88
91
  - **Cross-environment by design**: point `NEXUS_ENDPOINT` at the same host from different machines or shells. The host listens on `127.0.0.1` by default; turn on LAN access only on a trusted network.
89
92
 
90
93
  ## 🚀 Quick start
@@ -106,7 +109,7 @@ The host exposes 36 tools, grouped by purpose:
106
109
  | **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. |
107
110
  | **Messaging** | `send_message`, `read_messages`, `list_events` | Post meeting or global messages; read them and the activity log. |
108
111
  | **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. |
109
- | **Meetings** | `list_meetings`, `start_meeting`, `join_meeting`, `leave_meeting`, `stance`, `end_meeting`, `archive_meeting`, `reopen_meeting` | List and open meetings; join (woken by new messages) or leave them; endorse or object to the current draft, which closes the meeting by consensus; close, archive, and reopen them. |
112
+ | **Meetings** | `list_meetings`, `start_meeting`, `join_meeting`, `leave_meeting`, `stance`, `end_meeting`, `archive_meeting`, `reopen_meeting` | List and open meetings; join (woken by new messages) or leave them; endorse or object to the current draft, which closes the meeting by consensus or, with the user's seat, is decided by the user; close, archive, and reopen them. |
110
113
  | **Tasks** | `create_task`, `get_task`, `list_tasks`, `update_task`, `cancel_task` | Create, update and track tasks that assistants manage themselves. |
111
114
  | **Maintenance** | `host_maintenance`, `host_delete_project` | Prune or clear the global chat stream; run the retention cleanup now; export a meeting as markdown; delete a project. |
112
115
 
@@ -151,6 +154,10 @@ Yes. Run the host in one environment and point every adapter at it with `NEXUS_E
151
154
 
152
155
  No cloud service is needed. Data stays in the configured local directory. Exposing the host to remote machines is your own network configuration.
153
156
 
157
+ ### How long does it keep data?
158
+
159
+ Meetings for 30 days after they end, everything else (direct messages, events, finished tasks, inactive sessions) for 7 days; set `meetingRetentionDays` and `retentionDays` in the host's `settings.json`. `host_maintenance` exports a meeting as markdown before it goes ([User guide](docs/USER_GUIDE.md#settingsjson)).
160
+
154
161
  ### Is it a vector database or a source code indexer?
155
162
 
156
163
  No. n2n-nexus coordinates project metadata, messages, meetings, docs, tasks and assets; it does no semantic code search.
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 ?? [])
@@ -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}`;
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);
@@ -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,25 @@
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
+
14
+ ## [0.12.1] - 2026-10-29
15
+
16
+ ### 变更
17
+
18
+ - 新的托盘图标,README 的 logo 同步更换:炭黑圆角底加灰边,上面是一张小网络,中间一个红色节点,用浅蓝连线连到四个白色节点。取代托盘里原来的青色圆形和六边形 logo;`assets` 里旧的图标和横幅文件已删除。macOS 菜单栏只显示这张网络图形,用模板图,系统按菜单栏明暗显示成黑色或白色。
19
+
20
+ ### 修复
21
+
22
+ - host 因你自己的调用而发的消息(席位已占据、AI 共识提示、席位的批准或否决)对你算已读:席位入座或定完一点后可以立刻发下一稿,表态凑齐 AI 共识的成员也可以紧接着发言,不会因为那条消息被拒。
23
+ - 会议提醒不再重复 `read_messages` 以未读模式已经返回的消息:读过会议即视为已提醒,下一次 hook 提醒只显示之后新来的。
24
+ - 有用户席位的会议里,"审批人名单已变更"的消息不再列出席位会话,它不在任何审批人名单里。
25
+
7
26
  ## [0.12.0] - 2026-10-29
8
27
 
9
28
  每台机器上的 host 和客户端要一起升级(契约版本 4)。数据库原地升级:保留 `nexus.db`。
package/docs/README_zh.md CHANGED
@@ -31,6 +31,7 @@ n2n-nexus 是一个开源的 Model Context Protocol (MCP) 协同服务器,适
31
31
  - [实战示例](#-实战示例)
32
32
  - [FAQ](#-faq)
33
33
  - [文档](#-文档)
34
+ - [许可证](#-许可证)
34
35
 
35
36
  ## 🔎 搜索定位
36
37
 
@@ -50,7 +51,7 @@ n2n-nexus 面向这些场景设计:共享 host 状态、轻量 MCP 适配器
50
51
 
51
52
  n2n-nexus 为 AI 助手提供共享协作空间。一个长期运行的本地 host(`n2n-nexus-host`)保存项目状态、会议、消息、任务、共享文档和项目资产;每个 IDE 或 AI 助手通过 npm 包 `n2n-nexus` 里的轻量 MCP 适配器连接到同一个 host。
52
53
 
53
- **Quick summary**
54
+ **概要**
54
55
 
55
56
  - **安装**:host 用 Scoop / Homebrew / Release 压缩包安装 `n2n-nexus-host`;客户端 `npm i -g n2n-nexus@latest` 后执行 `n2n-nexus install <claude|codex>`
56
57
  - **协议**:Model Context Protocol (MCP),适配器通过 HTTP 连接 host
@@ -61,8 +62,10 @@ n2n-nexus 为 AI 助手提供共享协作空间。一个长期运行的本地 ho
61
62
  ## 🎯 为什么使用它?
62
63
 
63
64
  - 协调多个 AI 助手共同处理同一个项目。
65
+ - 让 Claude Code 和 Codex 对话之间直接发消息:消息通过客户端的 hooks 完整送达,会叫醒正在等待的对话,用 `reply` 回复;每个会话都显示它所在的机器和环境。
66
+ - 让助手们开会并留下决议。你参会时,你的对话坐在用户席位上:每份草案由你决定、会议由你结束,助手们的共识只作通报。
64
67
  - 在多个 IDE 和机器之间共享项目 Manifest、决策和资产。
65
- - 将决策、提案和更新保存在本地的会议和消息记录中。
68
+ - 将决策、提案和更新保存在本地的会议和消息记录中;旧数据按你设定的保留期清理。
66
69
  - host 启动一次即可,Windows、WSL、SSH 主机或多个编辑器都可以连接。
67
70
  - 避免为了交换上下文而给每个助手过宽的文件系统或后端权限。
68
71
 
@@ -106,7 +109,7 @@ host 提供 36 个工具,按用途分组:
106
109
  | **全局文档** | `list_global_docs`、`get_global_doc`、`sync_global_doc`、`get_global_strategy`、`update_global_strategy` | 列出、读取、创建和更新共享的全局文档和主策略文档。 |
107
110
  | **消息** | `send_message`、`read_messages`、`list_events` | 发送会议或全局消息;读取消息和活动日志。 |
108
111
  | **直接消息** | `peers`、`send`、`read`、`reply`、`listen`、`set_profile` | 查找其他 Claude Code / Codex 对话,给它们发消息,读取并回复收到的消息,等待消息,给自己起名。 |
109
- | **会议** | `list_meetings`、`start_meeting`、`join_meeting`、`leave_meeting`、`stance`、`end_meeting`、`archive_meeting`、`reopen_meeting` | 列出和开启会议;加入(有新消息会被叫醒)或退出;对当前草案认可或反对,全部认可即以共识结案;关闭、归档和重新打开会议。 |
112
+ | **会议** | `list_meetings`、`start_meeting`、`join_meeting`、`leave_meeting`、`stance`、`end_meeting`、`archive_meeting`、`reopen_meeting` | 列出和开启会议;加入(有新消息会被叫醒)或退出;对当前草案认可或反对,全部认可即以共识结案,有用户席位时由用户决定;关闭、归档和重新打开会议。 |
110
113
  | **任务** | `create_task`、`get_task`、`list_tasks`、`update_task`、`cancel_task` | 创建、更新和跟踪由助手自行管理的任务。 |
111
114
  | **维护** | `host_maintenance`、`host_delete_project` | 清理或清空全局聊天流;立即按保留期清理;把会议导出为 markdown;删除项目。 |
112
115
 
@@ -151,6 +154,10 @@ host 提供 36 个工具,按用途分组:
151
154
 
152
155
  不需要云服务。数据保存在配置的本地目录中。如果你将 host 暴露给远程机器,那属于你自己的网络配置。
153
156
 
157
+ ### 数据保留多久?
158
+
159
+ 会议结束后保留 30 天,其他数据(直接消息、事件、结束的任务、不活动的会话)保留 7 天;可在 host 的 `settings.json` 里用 `meetingRetentionDays` 和 `retentionDays` 调整。会议被删除前可以用 `host_maintenance` 导出为 markdown([使用手册](USER_GUIDE_zh.md#settingsjson))。
160
+
154
161
  ### 它是向量数据库或源码索引器吗?
155
162
 
156
163
  不是。n2n-nexus 协调项目元数据、消息、会议、文档、任务和资产,不做语义源码搜索。
@@ -192,7 +192,7 @@ Behavior:
192
192
  - Otherwise it is written to global chat.
193
193
  - A `DECISION` message in a meeting is also recorded in that meeting's decisions (`decisionCount` in `list_meetings`).
194
194
  - The sender stored with the message is the calling conversation's session ID, or the adapter instance ID without hooks.
195
- - Read first: a post to a meeting is refused while messages of others came after your read cursor for it. The cursor moves with `read_messages` in unread mode, with a notification that showed the messages whole and counted them read (see the delivery table above), and with a refusal; history reads (`latest`, `afterId`) do not move it. The refused message is not posted; the result is `{ ok: false, refused: true, sentTo: "meeting", meetingId, identity, unread, message }`, where `unread` is those messages (`[{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp, cut }]`, oldest first). Their texts are cut at 5000 characters in all (`notifyTextChars`): the message that reaches the limit is attached cut (`cut: true`) and later ones are left out; `read_messages` returns them. The messages attached whole now count as read and are not announced again.
195
+ - Read first: a post to a meeting is refused while messages of others came after your read cursor for it. The cursor moves with `read_messages` in unread mode, with a notification that showed the messages whole and counted them read (see the delivery table above), and with a refusal; history reads (`latest`, `afterId`) do not move it. The refused message is not posted; the result is `{ ok: false, refused: true, sentTo: "meeting", meetingId, identity, unread, message }`, where `unread` is those messages (`[{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp, cut }]`, oldest first). Their texts are cut at 5000 characters in all (`notifyTextChars`): the message that reaches the limit is attached cut (`cut: true`) and later ones are left out; `read_messages` returns them. The messages attached whole now count as read and are not announced again. A message the host posts because of your own call (the seat taken, the AI consensus notice, the seat's decision or veto) counts as read for you when you had read everything before it.
196
196
  - The next `send_message` to that meeting after a refusal goes through even if more messages arrived, with `[n2n-nexus: sent while #a–#b were unread by the sender.]` appended to its text; those messages stay unread. A draft (`draft: true`) or a `DECISION` is never let through: its text becomes a decision's, so it is posted only once everything is read. Global chat has no such check.
197
197
  - Result: `{ ok: true, sentTo: "meeting" | "global", meetingId?, messageId, identity, currentProposalId?, message }` (`currentProposalId` with `draft`); `messageId` is the message's number in its meeting, counted from 1 in each meeting, or its global chat id (use it with `afterId`).
198
198
 
@@ -358,7 +358,7 @@ Join an active meeting, or change how you take part in it.
358
358
  Behavior:
359
359
 
360
360
  - Result: `{ ok, meetingId, mode, approver, identity, delivery }`. `delivery` is `wake` (active), `next-hook` (async) or `unavailable`: a client without the hooks joins as its adapter instance, which nothing can notify.
361
- - News is announced through the hooks per meeting: `Meeting "<topic>" (<id>): N new message(s), #a–#b:`, then each message between `--- Meeting message #n from <sender> (<machine>) at <time>[ (<category>)] ---` and `--- End of meeting message #n ---`. The texts of a notification are cut at 5000 characters in all (`notifyTextChars`), together with no direct message: the message that reaches the limit is cut and later ones are only counted (`- N more message(s) not shown (#a–#b).`); `read_messages(meetingId)` returns them. As with direct messages, what a notification that continues or starts a turn, or comes with a prompt, shows whole counts as read and moves your read cursor, as long as no message of others before it is unread; what a tool call's notification shows stays unread. Your own messages never wake you. News of a meeting does not count as an automatic turn and still arrives past `maxAutoTurns`; a meeting runs until it is ended.
361
+ - News is announced through the hooks per meeting: `Meeting "<topic>" (<id>): N new message(s), #a–#b:`, then each message between `--- Meeting message #n from <sender> (<machine>) at <time>[ (<category>)] ---` and `--- End of meeting message #n ---`. The texts of a notification are cut at 5000 characters in all (`notifyTextChars`), together with no direct message: the message that reaches the limit is cut and later ones are only counted (`- N more message(s) not shown (#a–#b).`); `read_messages(meetingId)` returns them. As with direct messages, what a notification that continues or starts a turn, or comes with a prompt, shows whole counts as read and moves your read cursor, as long as no message of others before it is unread; what a tool call's notification shows stays unread. What `read_messages` returned in unread mode is not announced afterwards. Your own messages never wake you. News of a meeting does not count as an automatic turn and still arrives past `maxAutoTurns`; a meeting runs until it is ended.
362
362
  - Messages from before you joined are not announced. Joining again while a member only changes `mode` and `approver`; switch to `async` to pause. After `leave_meeting`, joining again starts from the newest message.
363
363
  - Records a `meeting.join` event.
364
364
 
@@ -192,7 +192,7 @@ HTTP 客户端:hooks 调用 `POST /api/hook`,请求体为 `{ client: "claude
192
192
  - 否则写入全局聊天。
193
193
  - 会议中的 `DECISION` 消息还会记入该会议的决策(`list_meetings` 中的 `decisionCount`)。
194
194
  - 随消息保存的发送方是调用方对话的会话 ID,没装 hooks 时是适配器实例 ID。
195
- - 先读后发:会议里在你的读取游标之后还有别人的消息时,发言被拒。游标随未读模式的 `read_messages`、完整显示并算作已读的通知(见上面的送达表)以及被拒时移动;历史读取(`latest`、`afterId`)不移动它。被拒的消息不会发出;结果为 `{ ok: false, refused: true, sentTo: "meeting", meetingId, identity, unread, message }`,`unread` 是那些消息(`[{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp, cut }]`,从旧到新)。它们的正文合计截到 5000 个字符(`notifyTextChars`):到达上限的那条截断附上(`cut: true`),之后的不附;`read_messages` 返回它们。完整附上的消息从此算已读,不再提醒。
195
+ - 先读后发:会议里在你的读取游标之后还有别人的消息时,发言被拒。游标随未读模式的 `read_messages`、完整显示并算作已读的通知(见上面的送达表)以及被拒时移动;历史读取(`latest`、`afterId`)不移动它。被拒的消息不会发出;结果为 `{ ok: false, refused: true, sentTo: "meeting", meetingId, identity, unread, message }`,`unread` 是那些消息(`[{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp, cut }]`,从旧到新)。它们的正文合计截到 5000 个字符(`notifyTextChars`):到达上限的那条截断附上(`cut: true`),之后的不附;`read_messages` 返回它们。完整附上的消息从此算已读,不再提醒。host 因你自己的调用而发的消息(席位已占据、AI 共识提示、席位的批准或否决),在你已读完它之前所有消息时对你算已读。
196
196
  - 被拒之后对该会议的下一次 `send_message` 即使又有新消息也会发出,正文末尾附上 `[n2n-nexus: sent while #a–#b were unread by the sender.]`;那些消息仍为未读。草案(`draft: true`)和 `DECISION` 不放行:它们的正文会成为决议,必须读完才能发。全局聊天没有这项检查。
197
197
  - 结果:`{ ok: true, sentTo: "meeting" | "global", meetingId?, messageId, identity, currentProposalId?, message }`(带 `draft` 时有 `currentProposalId`);`messageId` 是消息在所属会议里的编号(每个会议从 1 开始),或它的全局聊天 ID(可用于 `afterId`)。
198
198
 
@@ -358,7 +358,7 @@ HTTP 客户端:hooks 调用 `POST /api/hook`,请求体为 `{ client: "claude
358
358
  行为:
359
359
 
360
360
  - 结果:`{ ok, meetingId, mode, approver, identity, delivery }`。`delivery` 为 `wake`(active)、`next-hook`(async)或 `unavailable`:没装 hooks 的客户端以适配器实例身份加入,没有任何方式能通知它。
361
- - 新消息通过 hooks 按会议提醒:`Meeting "<主题>" (<id>): N new message(s), #a–#b:`,然后每条消息夹在 `--- Meeting message #n from <发送方> (<机器>) at <时间>[ (<类别>)] ---` 和 `--- End of meeting message #n ---` 之间。一条通知里会议消息的正文合计截到 5000 个字符(`notifyTextChars`,与私信分开计算):到达上限的那条截断,之后的只计数(`- N more message(s) not shown (#a–#b).`);`read_messages(meetingId)` 返回它们。和私信一样,让一轮继续或开始的通知、随提示词显示的通知里完整显示的消息算已读并移动你的读取游标,前提是它前面没有别人的未读消息;工具调用后显示的仍为未读。自己发的消息不会叫醒自己。会议新消息不算自动轮次,超过 `maxAutoTurns` 后仍会送达;会议一直进行到被结束为止。
361
+ - 新消息通过 hooks 按会议提醒:`Meeting "<主题>" (<id>): N new message(s), #a–#b:`,然后每条消息夹在 `--- Meeting message #n from <发送方> (<机器>) at <时间>[ (<类别>)] ---` 和 `--- End of meeting message #n ---` 之间。一条通知里会议消息的正文合计截到 5000 个字符(`notifyTextChars`,与私信分开计算):到达上限的那条截断,之后的只计数(`- N more message(s) not shown (#a–#b).`);`read_messages(meetingId)` 返回它们。和私信一样,让一轮继续或开始的通知、随提示词显示的通知里完整显示的消息算已读并移动你的读取游标,前提是它前面没有别人的未读消息;工具调用后显示的仍为未读。`read_messages` 以未读模式返回过的消息之后不再提醒。自己发的消息不会叫醒自己。会议新消息不算自动轮次,超过 `maxAutoTurns` 后仍会送达;会议一直进行到被结束为止。
362
362
  - 加入前的消息不会提醒。已是成员时再次加入只修改 `mode` 和 `approver`;改为 `async` 即可暂停。`leave_meeting` 之后再加入,从最新一条开始。
363
363
  - 记录 `meeting.join` 事件。
364
364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-nexus",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Local-first MCP coordination hub from N2NS Lab for multi-AI assistant collaboration.",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
Binary file
Binary file