n2n-nexus 0.9.5 → 0.11.0
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 +30 -0
- package/LICENSE +1 -1
- package/README.md +2 -2
- package/build/cli/doctor.js +2 -1
- package/build/cli/hook.js +33 -9
- package/build/cli/install.js +59 -0
- package/build/cli/machine.js +27 -0
- package/build/cli/mcp-config.js +32 -11
- package/build/client/discovery.js +11 -2
- package/build/client/nexus-client.js +1 -1
- package/build/server/nexus.js +8 -3
- package/contract/errors.json +2 -2
- package/contract/hook.json +46 -7
- package/contract/tools.json +109 -12
- package/docs/ARCHITECTURE.md +10 -6
- package/docs/ARCHITECTURE_zh.md +10 -6
- package/docs/ASSISTANT_GUIDE.md +4 -4
- package/docs/ASSISTANT_GUIDE_zh.md +4 -4
- package/docs/CHANGELOG_zh.md +30 -0
- package/docs/INSTALL.md +11 -5
- package/docs/INSTALL_zh.md +11 -5
- package/docs/README_zh.md +2 -2
- package/docs/TOOLS_REFERENCE.md +9 -9
- package/docs/TOOLS_REFERENCE_zh.md +9 -9
- package/docs/USER_GUIDE.md +5 -2
- package/docs/USER_GUIDE_zh.md +5 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,8 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project will be documented in this file.
|
|
6
6
|
|
|
7
|
+
## [0.11.0] - 2026-10-29
|
|
8
|
+
|
|
9
|
+
Upgrade the host and the client on every machine together, and delete `nexus.db` from the host's data directory before the new host starts: this release starts a new database and contract version, and an older host or client no longer works with it.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Sessions show the machine they run on. Every hook event carries `machine` (hostname without `.local`, platform `darwin` / `linux` / `wsl` / `win32`, local time minus UTC in minutes); the host refuses an event without it. `peers` and `set_profile` return `hostname`, `platform`, `utcOffset` and `label`; introductions and notifications show `name (<label or hostname> · <platform>)`; `read_messages` returns `senderMachine`. `set_profile({ label })` names the machine in place of its hostname.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Contract version 3: the host and the client must be upgraded together (`n2n-nexus doctor` reports a mismatch).
|
|
18
|
+
- The database schema is two files, one per owner, with no upgrade path from earlier databases: delete `nexus.db` before starting this host (it then says so if an old file is left).
|
|
19
|
+
- Notifications, the collaboration rules, the tool descriptions and the MCP instructions state facts only (sender, text, whether and how to reply) and no longer claim that a message is an approved instruction; that authorization lives only in the `CLAUDE.md` block that `install claude` writes. A Claude Code recipient gets the reply tool's full name.
|
|
20
|
+
- `send` / `read` / `reply` and `send_message` / `read_messages` name each other in their first sentence; the introduction, `peers` and the MCP instructions say that the client's own agent list does not show nexus sessions; `send` asks the sender to end its turn instead of polling with `read`.
|
|
21
|
+
|
|
22
|
+
## [0.10.0] - 2026-10-28
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `n2n-nexus install claude` writes a marked block into the user-level `CLAUDE.md` (`<!-- n2n-nexus:begin … -->` to `<!-- n2n-nexus:end -->`): a direct message from another session is the user's own instruction, carried out exactly and without asking the user for confirmation, with questions going back to the sender. Claude Code acts on another session's message only where the user's own instructions call for it, which is why the block is needed. The rest of the file is kept; `uninstall claude` removes the block, and the file when nothing else is in it. Run `install claude` again after upgrading to get the block.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Direct messages arrive whole: a notification shows each message's text between `--- Message <id> from … ---` and `--- End of message <id> ---`, with how to answer it, instead of the sender and the first line. The texts of one notification together are cut at 5000 characters (`notifyTextChars` in the contract); a cut message is marked and `read` returns it. A message shown whole is marked read when the notification continues or starts the turn (Stop, listening, background wake), and, when it needs no reply, when a prompt shows it; `reply` marks the message it answers read. Other messages stay unread for `read`, among them every message shown after a tool call, because that context can reach a sub-agent only.
|
|
31
|
+
- The notifications, the SessionStart introduction and the MCP instructions say that the sessions belong to the same user: a message is that user's instruction, carried out exactly and without asking the user for confirmation, nothing beyond it; when more work or missing information seems needed, the sender is asked with `reply`. `send`'s guidance is to put everything the recipient needs into one message; a task is answered once with its result, or with what stopped it and what would let it go on.
|
|
32
|
+
|
|
7
33
|
## [0.9.5] - 2026-10-26
|
|
8
34
|
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Host on Windows: `n2n-nexus-host.exe install [--dir <dir>]` installs the host from an extracted release zip into `%LOCALAPPDATA%\Programs\n2n-nexus-host`, turns start at login on the first time and starts the tray. Run it again from a newer release to upgrade: it replaces the programs (running ones are moved aside and removed by the next install), restarts the tray and, after an administrator prompt, restarts the background service on the new version for the same data directory.
|
|
38
|
+
|
|
9
39
|
### Changed
|
|
10
40
|
|
|
11
41
|
- `n2n-nexus install codex` trusts the n2n-nexus hooks in Codex itself: it asks Codex (`codex app-server`, the copy the Codex app runs on Windows, else `codex` on `PATH`) for each hook's trust hash and stores it, so no `/hooks` step is needed. If that fails, it says how to trust them by hand (the Codex app's settings, or `/hooks` in the CLI). `n2n-nexus doctor codex` checks with Codex that the hooks are trusted, which a Codex update can undo.
|
package/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright
|
|
189
|
+
Copyright 2025-2026 Datafrog LLC
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ Other MCP clients and several machines: see the [Installation guide](docs/INSTAL
|
|
|
98
98
|
|
|
99
99
|
## 🛠️ Toolset
|
|
100
100
|
|
|
101
|
-
The host exposes
|
|
101
|
+
The host exposes 36 tools, grouped by purpose:
|
|
102
102
|
|
|
103
103
|
| Group | Tools | Purpose |
|
|
104
104
|
| --- | --- | --- |
|
|
@@ -107,7 +107,7 @@ The host exposes 33 tools, grouped by purpose:
|
|
|
107
107
|
| **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. |
|
|
108
108
|
| **Messaging** | `send_message`, `read_messages`, `list_events` | Post meeting or global messages; read them and the activity log. |
|
|
109
109
|
| **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. |
|
|
110
|
-
| **Meetings** | `list_meetings`, `start_meeting`, `end_meeting`, `archive_meeting`, `reopen_meeting` | List
|
|
110
|
+
| **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. |
|
|
111
111
|
| **Tasks** | `create_task`, `get_task`, `list_tasks`, `update_task`, `cancel_task` | Create, update and track tasks that assistants manage themselves. |
|
|
112
112
|
| **Maintenance** | `host_maintenance`, `host_delete_project` | Prune or clear the global chat stream; delete a project. |
|
|
113
113
|
|
package/build/cli/doctor.js
CHANGED
|
@@ -81,7 +81,7 @@ async function doctorHosts(endpoints) {
|
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
83
|
* Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed.
|
|
84
|
-
* Adds the endpoints its hook commands use
|
|
84
|
+
* Adds the endpoints its hook and MCP commands use to `endpoints`, falling back to defaultEndpoint.
|
|
85
85
|
*/
|
|
86
86
|
function doctorClient(client, endpoints, defaultEndpoint) {
|
|
87
87
|
const hooksFile = configFile(client);
|
|
@@ -132,6 +132,7 @@ function doctorClient(client, endpoints, defaultEndpoint) {
|
|
|
132
132
|
report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
|
|
133
133
|
}
|
|
134
134
|
else {
|
|
135
|
+
endpoints.add(mcp.endpoint === undefined ? defaultEndpoint : mcp.endpoint || LOCAL_ENDPOINT);
|
|
135
136
|
report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
|
|
136
137
|
const [npx, ...rest] = mcp.args;
|
|
137
138
|
if (npx && rest.join(" ") === `-y ${MCP_PACKAGE} mcp`)
|
package/build/cli/hook.js
CHANGED
|
@@ -10,6 +10,7 @@ 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
12
|
import { DISCOVER_ENDPOINT, LOCAL_ENDPOINT, resolveEndpoint } from "../client/discovery.js";
|
|
13
|
+
import { machineInfo } from "./machine.js";
|
|
13
14
|
import { readTitle } from "./title.js";
|
|
14
15
|
/** Errors after which a discovered daemon is looked up again: it was never reached, so resending is safe. */
|
|
15
16
|
const UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT", "EADDRNOTAVAIL"]);
|
|
@@ -48,6 +49,8 @@ async function forward(argv) {
|
|
|
48
49
|
if (title !== undefined)
|
|
49
50
|
input.title = title;
|
|
50
51
|
}
|
|
52
|
+
// Every event says where the conversation runs; the host refuses one without it.
|
|
53
|
+
input.machine = machineInfo();
|
|
51
54
|
// SessionEnd must finish within Codex's 3 s hook limit (discovery included): a short budget and a single attempt.
|
|
52
55
|
const sessionEnd = stdin.hook_event_name === "SessionEnd";
|
|
53
56
|
const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : sessionEnd ? SESSION_END_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
|
|
@@ -90,19 +93,41 @@ async function forward(argv) {
|
|
|
90
93
|
}
|
|
91
94
|
/** The background wait ends before the client's hook timeout would kill it during a request. */
|
|
92
95
|
const WAIT_DEADLINE_MS = (WAIT_HOOK_TIMEOUT_SEC - 10) * 1000;
|
|
96
|
+
const WAIT_RETRY_CODES = new Set([...UNREACHABLE_CODES, "ECONNRESET", "EPIPE", "ENOTFOUND", "EAI_AGAIN", REQUEST_TIMEOUT_CODE]);
|
|
93
97
|
/**
|
|
94
98
|
* Ask the daemon until something arrives for the session (the wake), the
|
|
95
99
|
* session becomes active again or the deadline passes (null).
|
|
96
100
|
*/
|
|
97
|
-
export async function waitForWake(client, sessionId,
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
export async function waitForWake(client, sessionId, configured, deadlineMs = WAIT_DEADLINE_MS) {
|
|
102
|
+
const deadline = Date.now() + deadlineMs;
|
|
103
|
+
let endpoint = await resolveEndpoint(configured);
|
|
104
|
+
let delay = WAKE_RETRY_FIRST_DELAY_MS;
|
|
105
|
+
let fresh = false;
|
|
101
106
|
while (Date.now() < deadline) {
|
|
102
|
-
|
|
107
|
+
if (fresh)
|
|
108
|
+
endpoint = await resolveEndpoint(configured, { fresh: true });
|
|
109
|
+
const remaining = deadline - Date.now();
|
|
110
|
+
if (remaining <= 0)
|
|
111
|
+
return null;
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await new NexusClient({ endpoint }).hook({ client, input: { hook_event_name: BACKGROUND_WAIT_EVENT, session_id: sessionId } }, Math.min(HOOK_STOP_REQUEST_TIMEOUT_MS, remaining));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (!(error instanceof NexusTransportError) || !WAIT_RETRY_CODES.has(error.code ?? ""))
|
|
118
|
+
throw error;
|
|
119
|
+
if (Date.now() + delay >= deadline)
|
|
120
|
+
return null;
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
122
|
+
delay = Math.min(delay * 2, WAKE_RETRY_MAX_DELAY_MS);
|
|
123
|
+
fresh = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
fresh = false;
|
|
127
|
+
delay = WAKE_RETRY_FIRST_DELAY_MS;
|
|
103
128
|
const output = isPlainObject(response) ? response.output : null;
|
|
104
129
|
if (isPlainObject(output) && typeof output.wake === "string" && typeof output.dispatchId === "string")
|
|
105
|
-
return { text: output.wake, dispatchId: output.dispatchId };
|
|
130
|
+
return { text: output.wake, dispatchId: output.dispatchId, endpoint };
|
|
106
131
|
if (!(isPlainObject(output) && output.timeout === true))
|
|
107
132
|
return null;
|
|
108
133
|
}
|
|
@@ -143,10 +168,9 @@ async function runWait(argv) {
|
|
|
143
168
|
// Load what a wake needs before waiting: the wait lasts up to an hour, and an
|
|
144
169
|
// upgrade or a cleared npx cache may remove the package's files meanwhile.
|
|
145
170
|
const { spawnClientSync } = client === "codex" ? await import("./client-command.js") : { spawnClientSync: undefined };
|
|
146
|
-
const endpoint = await resolveEndpoint(configured);
|
|
147
171
|
const deadline = Date.now() + WAIT_DEADLINE_MS;
|
|
148
172
|
for (let delay = WAKE_RETRY_FIRST_DELAY_MS;; delay = Math.min(delay * 2, WAKE_RETRY_MAX_DELAY_MS)) {
|
|
149
|
-
const wake = await waitForWake(client, sessionId,
|
|
173
|
+
const wake = await waitForWake(client, sessionId, configured, deadline - Date.now());
|
|
150
174
|
if (wake === null)
|
|
151
175
|
return 0;
|
|
152
176
|
if (!spawnClientSync) {
|
|
@@ -157,7 +181,7 @@ async function runWait(argv) {
|
|
|
157
181
|
const queued = spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", wake.text], { stdio: "ignore", windowsHide: true });
|
|
158
182
|
const ok = !queued.error && queued.status === 0;
|
|
159
183
|
const input = { hook_event_name: WAKE_RESULT_EVENT, session_id: sessionId, dispatch_id: wake.dispatchId, ok };
|
|
160
|
-
const reported = await new NexusClient({ endpoint }).hook({ client, input }, HOOK_REQUEST_TIMEOUT_MS).then(() => true, () => false);
|
|
184
|
+
const reported = await new NexusClient({ endpoint: wake.endpoint }).hook({ client, input }, HOOK_REQUEST_TIMEOUT_MS).then(() => true, () => false);
|
|
161
185
|
// Unknown result (no report): the host keeps the wake, and waiting again would deliver nothing.
|
|
162
186
|
if (ok || !reported || Date.now() + delay >= deadline)
|
|
163
187
|
return 0;
|
package/build/cli/install.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* contains both `n2n-nexus` and ` hook <client>`; everything else in the
|
|
6
6
|
* configuration file is kept as it is.
|
|
7
7
|
*
|
|
8
|
+
* For Claude Code, install also writes a marked block into the user's
|
|
9
|
+
* CLAUDE.md that makes direct messages count as the user's own requests
|
|
10
|
+
* (Claude Code acts on another session's message only where the user's own
|
|
11
|
+
* instructions call for it); uninstall removes the block.
|
|
12
|
+
*
|
|
8
13
|
* The commands start with an absolute Node path: GUI clients started from the
|
|
9
14
|
* Dock do not read the shell PATH. The `node` found on PATH is preferred when it
|
|
10
15
|
* is the running binary, because process.execPath is the resolved, versioned
|
|
@@ -260,6 +265,57 @@ function writeConfig(file, config) {
|
|
|
260
265
|
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
261
266
|
fs.renameSync(tmp, target);
|
|
262
267
|
}
|
|
268
|
+
/** 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
|
+
export const INSTRUCTIONS = [
|
|
272
|
+
INSTRUCTIONS_BEGIN,
|
|
273
|
+
"## n2n-nexus direct messages",
|
|
274
|
+
"",
|
|
275
|
+
"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
|
+
INSTRUCTIONS_END
|
|
277
|
+
].join("\n");
|
|
278
|
+
export function instructionsFile() {
|
|
279
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "CLAUDE.md");
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Write our block into the user's CLAUDE.md (replacing an earlier one), or
|
|
283
|
+
* remove it; a file that holds nothing else is removed. Returns whether the file changed.
|
|
284
|
+
*/
|
|
285
|
+
function writeInstructions(install) {
|
|
286
|
+
const file = instructionsFile();
|
|
287
|
+
const target = linkTarget(file);
|
|
288
|
+
let text = "";
|
|
289
|
+
try {
|
|
290
|
+
text = fs.readFileSync(target, "utf8");
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error.code !== "ENOENT")
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
// The user's text around the block stays as it is; only the blank lines that joined it to the block go.
|
|
297
|
+
let rest = text;
|
|
298
|
+
const begin = text.indexOf(INSTRUCTIONS_BEGIN);
|
|
299
|
+
const end = begin === -1 ? -1 : text.indexOf(INSTRUCTIONS_END, begin);
|
|
300
|
+
if (end !== -1) {
|
|
301
|
+
const before = text.slice(0, begin).replace(/\n+$/, "");
|
|
302
|
+
const after = text.slice(end + INSTRUCTIONS_END.length).replace(/^\n+/, "");
|
|
303
|
+
rest = before && after ? `${before}\n\n${after}` : before ? `${before}\n` : after;
|
|
304
|
+
}
|
|
305
|
+
const kept = rest.replace(/\n+$/, "");
|
|
306
|
+
const next = install ? `${kept ? `${kept}\n\n` : ""}${INSTRUCTIONS}\n` : rest;
|
|
307
|
+
if (next === text)
|
|
308
|
+
return false;
|
|
309
|
+
if (!next.trim()) {
|
|
310
|
+
fs.rmSync(target, { force: true });
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
314
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
315
|
+
fs.writeFileSync(tmp, next);
|
|
316
|
+
fs.renameSync(tmp, target);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
263
319
|
function mcpRemoveArgs(client) {
|
|
264
320
|
return client === "claude"
|
|
265
321
|
? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
|
|
@@ -364,6 +420,9 @@ export function runInstall(options) {
|
|
|
364
420
|
writeConfig(file, config);
|
|
365
421
|
process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
|
|
366
422
|
}
|
|
423
|
+
if (client === "claude" && writeInstructions(action === "install")) {
|
|
424
|
+
process.stdout.write(`${action === "install" ? "Wrote the n2n-nexus instructions to" : "Removed the n2n-nexus instructions from"} ${instructionsFile()}\n`);
|
|
425
|
+
}
|
|
367
426
|
removeMcpServer(client, action, removed);
|
|
368
427
|
if (action === "install" && node && npm) {
|
|
369
428
|
const addArgs = mcpAddArgs(client, node, npm, endpoint);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this conversation runs, which the hook command adds to the input of
|
|
3
|
+
* every event it forwards as `machine`, so that other sessions can tell the machines apart
|
|
4
|
+
* (several sessions share a name and a working directory across machines).
|
|
5
|
+
* Node built-ins only: the hook command imports this on client events.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
/** darwin, linux, win32 as Node reports them; wsl for a Linux that runs under Windows. */
|
|
10
|
+
export function platformOf(platform = process.platform, readProcVersion = () => fs.readFileSync("/proc/version", "utf8")) {
|
|
11
|
+
if (platform !== "linux")
|
|
12
|
+
return platform;
|
|
13
|
+
try {
|
|
14
|
+
return /microsoft/i.test(readProcVersion()) ? "wsl" : "linux";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "linux";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The host name without the `.local` suffix that macOS adds. */
|
|
21
|
+
export function hostnameOf(hostname = os.hostname()) {
|
|
22
|
+
return hostname.replace(/\.local$/i, "");
|
|
23
|
+
}
|
|
24
|
+
export function machineInfo(now = new Date()) {
|
|
25
|
+
// 0 - x, not -x: at UTC -x is -0, which JSON writes as 0.
|
|
26
|
+
return { hostname: hostnameOf(), platform: platformOf(), utcOffset: 0 - now.getTimezoneOffset() };
|
|
27
|
+
}
|
package/build/cli/mcp-config.js
CHANGED
|
@@ -39,20 +39,27 @@ export function readMcpCommand(client, file) {
|
|
|
39
39
|
const server = servers?.[MCP_SERVER_NAME];
|
|
40
40
|
if (!server || typeof server.command !== "string")
|
|
41
41
|
return undefined;
|
|
42
|
-
|
|
42
|
+
const endpoint = server.env?.NEXUS_ENDPOINT;
|
|
43
|
+
return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [],
|
|
44
|
+
...(typeof endpoint === "string" ? { endpoint } : {}) };
|
|
43
45
|
}
|
|
44
46
|
// Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command and args.
|
|
45
47
|
const lines = text.split("\n");
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
+
const readTable = (suffix = "") => {
|
|
49
|
+
const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.(["']?)${MCP_SERVER_NAME}\\1${suffix}\\]\\s*(?:#.*)?$`).test(line));
|
|
50
|
+
if (start === -1)
|
|
51
|
+
return undefined;
|
|
52
|
+
const body = [];
|
|
53
|
+
for (const line of lines.slice(start + 1)) {
|
|
54
|
+
if (/^\s*\[/.test(line))
|
|
55
|
+
break;
|
|
56
|
+
body.push(line);
|
|
57
|
+
}
|
|
58
|
+
return body.join("\n");
|
|
59
|
+
};
|
|
60
|
+
const table = readTable();
|
|
61
|
+
if (table === undefined)
|
|
48
62
|
return undefined;
|
|
49
|
-
const body = [];
|
|
50
|
-
for (const line of lines.slice(start + 1)) {
|
|
51
|
-
if (/^\s*\[/.test(line))
|
|
52
|
-
break;
|
|
53
|
-
body.push(line);
|
|
54
|
-
}
|
|
55
|
-
const table = body.join("\n");
|
|
56
63
|
const value = String.raw `"(?:[^"\\\r\n]|\\.)*"|'[^'\r\n]*'`;
|
|
57
64
|
const decode = (s) => s.startsWith("'") ? s.slice(1, -1) : JSON.parse(s);
|
|
58
65
|
const command = new RegExp(`^\\s*command\\s*=\\s*(${value})`, "m").exec(table);
|
|
@@ -62,5 +69,19 @@ export function readMcpCommand(client, file) {
|
|
|
62
69
|
const array = new RegExp(`^\\s*args\\s*=\\s*\\[((?:${value}|#[^\\n]*|[^\\]"'#])*)\\]`, "m").exec(table);
|
|
63
70
|
const args = array ? [...array[1].matchAll(new RegExp(`(${value})|#[^\\n]*`, "g"))]
|
|
64
71
|
.filter((m) => m[1] !== undefined).map((m) => decode(m[1])) : [];
|
|
65
|
-
|
|
72
|
+
const endpointKey = `(?:NEXUS_ENDPOINT|"NEXUS_ENDPOINT"|'NEXUS_ENDPOINT')`;
|
|
73
|
+
const envTable = readTable(String.raw `\.(?:env|"env"|'env')`);
|
|
74
|
+
let endpoint = envTable === undefined ? undefined : new RegExp(`^\\s*${endpointKey}\\s*=\\s*(${value})`, "m").exec(envTable)?.[1];
|
|
75
|
+
endpoint ??= new RegExp(`^\\s*env\\.${endpointKey}\\s*=\\s*(${value})`, "m").exec(table)?.[1];
|
|
76
|
+
if (endpoint === undefined) {
|
|
77
|
+
const inline = new RegExp(`^\\s*env\\s*=\\s*\\{((?:${value}|#[^\\n]*|[^}"'#])*)\\}`, "m").exec(table)?.[1];
|
|
78
|
+
if (inline !== undefined) {
|
|
79
|
+
// Consume complete key/value pairs: endpoint-looking text inside another value is not a setting.
|
|
80
|
+
for (const entry of inline.matchAll(new RegExp(`(${value}|[A-Za-z0-9_-]+)\\s*=\\s*(${value})|#[^\\n]*`, "g"))) {
|
|
81
|
+
if (entry[1] && (entry[1] === "NEXUS_ENDPOINT" || /^["']/.test(entry[1]) && decode(entry[1]) === "NEXUS_ENDPOINT"))
|
|
82
|
+
endpoint = entry[2];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { command: decode(command[1]), args, ...(endpoint === undefined ? {} : { endpoint: decode(endpoint) }) };
|
|
66
87
|
}
|
|
@@ -12,6 +12,7 @@ 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
|
+
import { isIPv6 } from "node:net";
|
|
15
16
|
/** Endpoint value that turns discovery on (NEXUS_ENDPOINT or --endpoint). */
|
|
16
17
|
export const DISCOVER_ENDPOINT = "discover";
|
|
17
18
|
export const LOCAL_ENDPOINT = "http://127.0.0.1:5688";
|
|
@@ -47,7 +48,13 @@ export function discoverEndpoint(udpPort = discoveryPort(), waitMs = DISCOVERY_W
|
|
|
47
48
|
// listens on all interfaces, else the one address it is bound to.
|
|
48
49
|
const own = new Set(lanInterfaces().map((a) => a.address));
|
|
49
50
|
const address = (rinfo, reply) => {
|
|
50
|
-
|
|
51
|
+
const local = own.has(rinfo.address) || rinfo.address.startsWith("127.");
|
|
52
|
+
if (typeof reply.host === "string" && isIPv6(reply.host)) {
|
|
53
|
+
const host = new URL(`http://[${reply.host}]`).hostname;
|
|
54
|
+
// The IPv4 discovery source cannot identify an IPv6-only listener on another machine.
|
|
55
|
+
return host === "[::]" ? (local ? "[::1]" : null) : host;
|
|
56
|
+
}
|
|
57
|
+
if (!local)
|
|
51
58
|
return rinfo.address;
|
|
52
59
|
return typeof reply.host === "string" && !ALL_INTERFACES.has(reply.host) ? reply.host : "127.0.0.1";
|
|
53
60
|
};
|
|
@@ -68,7 +75,9 @@ export function discoverEndpoint(udpPort = discoveryPort(), waitMs = DISCOVERY_W
|
|
|
68
75
|
try {
|
|
69
76
|
const reply = JSON.parse(msg.toString("utf8"));
|
|
70
77
|
if (reply.service === "n2n-nexus" && Number.isInteger(reply.port) && reply.port > 0) {
|
|
71
|
-
|
|
78
|
+
const host = address(rinfo, reply);
|
|
79
|
+
if (host !== null)
|
|
80
|
+
finish(`http://${host}:${reply.port}`);
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
catch { /* not a discovery reply */ }
|
|
@@ -50,7 +50,7 @@ export class NexusClient {
|
|
|
50
50
|
}, (res) => {
|
|
51
51
|
let data = "";
|
|
52
52
|
res.setEncoding("utf8");
|
|
53
|
-
const interrupted = () => reject(new NexusTransportError("Response from n2n-nexus-host ended before completion"));
|
|
53
|
+
const interrupted = () => reject(new NexusTransportError("Response from n2n-nexus-host ended before completion", "ECONNRESET"));
|
|
54
54
|
res.on("aborted", interrupted);
|
|
55
55
|
res.on("error", (error) => reject(new NexusTransportError(error.message)));
|
|
56
56
|
res.on("close", () => { if (!res.complete)
|
package/build/server/nexus.js
CHANGED
|
@@ -11,9 +11,14 @@ const INITIAL_LIST_WAIT_MS = 1000;
|
|
|
11
11
|
const SERVICE_NAME = "n2n-nexus";
|
|
12
12
|
/** Sent to the client on connect so the model knows it can talk to other AI sessions. */
|
|
13
13
|
const INSTRUCTIONS = "n2n-nexus connects this conversation with other AI sessions (Claude Code and Codex conversations, " +
|
|
14
|
-
"possibly on other machines). Direct messages: call peers to see who is online
|
|
15
|
-
"
|
|
16
|
-
"
|
|
14
|
+
"possibly on other machines), all of your own user. Direct messages: call peers to see who is online (your " +
|
|
15
|
+
"client's own agent list, such as ListAgents, does not show these sessions), send to message a session (everything " +
|
|
16
|
+
"the recipient needs in one message), reply to answer one and read for the messages a notification cut or did not " +
|
|
17
|
+
"show. What a message may ask of you is set by your user's own instructions; when something is missing, ask the " +
|
|
18
|
+
"sender with reply. After send, end your turn instead of polling with read: the answer arrives through the hooks " +
|
|
19
|
+
"after your next tool call or at the end of a turn (at your user's next prompt once the automatic-turn limit is " +
|
|
20
|
+
"reached), and notifyWhenIdle tells you when the recipient finishes its turn (set expectsReply and wake false on a " +
|
|
21
|
+
"message that needs no answer, and skip plain acknowledgements); listen waits for messages after each turn. " +
|
|
17
22
|
"Direct messaging needs the n2n-nexus hooks. Separately, send_message and read_messages are the shared " +
|
|
18
23
|
"global chat and meetings, not direct messages. Session ids in peers (claude:<id>, codex:<id>) are what send " +
|
|
19
24
|
"takes; with the hooks, chat and meetings also record senders and unread cursors per session id " +
|
package/contract/errors.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
2
|
+
"contractVersion": 3,
|
|
3
3
|
"errorBody": {
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"ok": true,
|
|
22
22
|
"service": "n2n-nexus",
|
|
23
23
|
"version": "<host version>",
|
|
24
|
-
"contractVersion":
|
|
24
|
+
"contractVersion": 3,
|
|
25
25
|
"sessions": {
|
|
26
26
|
"online": "<number of agent sessions not offline>"
|
|
27
27
|
}
|
package/contract/hook.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
2
|
+
"contractVersion": 3,
|
|
3
3
|
"clients": [
|
|
4
4
|
"claude",
|
|
5
5
|
"codex"
|
|
@@ -56,16 +56,26 @@
|
|
|
56
56
|
"maxTitleChars": 80,
|
|
57
57
|
"sessionNamePattern": "^[A-Za-z0-9._@#-]{1,48}$",
|
|
58
58
|
"previewChars": 100,
|
|
59
|
-
"notifyListLimit": 5
|
|
59
|
+
"notifyListLimit": 5,
|
|
60
|
+
"notifyTextChars": 5000,
|
|
61
|
+
"maxHostnameChars": 80
|
|
60
62
|
},
|
|
61
63
|
"texts": {
|
|
62
64
|
"noticePrefix": "[n2n-nexus]",
|
|
63
|
-
"notifyClosing": "
|
|
64
|
-
"stopClosing": "
|
|
65
|
+
"notifyClosing": "Where a reply is asked, answer with reply once the work is done.",
|
|
66
|
+
"stopClosing": "Do the work, and where a reply is asked, answer with reply before you finish.",
|
|
65
67
|
"statusNote": "Only listening sessions answer right away; idle and working ones see a message when their user next sends a prompt or they next call a tool.",
|
|
66
|
-
"sessionStartClosing": "Call peers for the current list, send to message a session,
|
|
67
|
-
"
|
|
68
|
-
"
|
|
68
|
+
"sessionStartClosing": "Call peers for the current list (your client's own agent list, such as ListAgents, does not show these sessions), send to message a session, reply to answer a message you received and read for the messages a notification cut or did not show; set_profile tells others what you work on. Meetings and the global chat use send_message and read_messages instead.",
|
|
69
|
+
"collaborationRules": "All sessions on n2n-nexus belong to your own user. A direct message shows who sent it, its text and whether it wants an answer; what you may do for it is set by your user's own instructions. When something you need is missing, ask the session that sent it with reply. When you hand work to another session, put everything it needs into one message: the goal, the background, what you already tried and what a finished result looks like; then end your turn: do not poll with read, the answer reaches you through the hooks after your next tool call or at the end of a turn (at your user's next prompt once the automatic-turn limit is reached), and notifyWhenIdle tells you when the recipient finishes. Answer a task once, with its result; if you cannot finish, say what stopped you and the one thing that would let you go on. Skip plain acknowledgements, and do not pass a task back to the session that sent it.",
|
|
70
|
+
"messageTrustNote": "These messages come from other AI sessions of your own user. When something you need is missing, ask the sender with reply. Answer once, with the result; if you cannot finish, say what stopped you and the one thing that would let you go on. Skip plain acknowledgements.",
|
|
71
|
+
"messageInfoNote": "These messages come from other AI sessions of your own user; they need no reply.",
|
|
72
|
+
"messageHeader": "--- Message %s from %s (%s) at %s ---",
|
|
73
|
+
"messageEnd": "--- End of message %s ---",
|
|
74
|
+
"messageReplyTo": "This answers your message %s.",
|
|
75
|
+
"messageCut": "[Cut at %d of %d characters; read returns the whole message.]",
|
|
76
|
+
"replyInstruction": "Answer it with reply (messageId %s; expectsReply false for a result, true for a question).",
|
|
77
|
+
"readRest": "read returns the messages that are cut or not shown here.",
|
|
78
|
+
"replyTool": "The reply tool is %s.",
|
|
69
79
|
"noReplyMark": "(no reply needed)",
|
|
70
80
|
"earlierUnread": "%d earlier direct message(s) still unread; read them with read.",
|
|
71
81
|
"meetingNews": "Meeting \"%s\" (%s): %d new message(s) (#%d–#%d) from %s.",
|
|
@@ -168,6 +178,35 @@
|
|
|
168
178
|
"required": [
|
|
169
179
|
"tool"
|
|
170
180
|
]
|
|
181
|
+
},
|
|
182
|
+
"hookMachine": {
|
|
183
|
+
"description": "input.machine, which every hook event of a conversation carries (not BackgroundWait or WakeResult): where the conversation runs",
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"hostname": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"minLength": 1
|
|
189
|
+
},
|
|
190
|
+
"platform": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"enum": [
|
|
193
|
+
"darwin",
|
|
194
|
+
"linux",
|
|
195
|
+
"wsl",
|
|
196
|
+
"win32"
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
"utcOffset": {
|
|
200
|
+
"type": "integer",
|
|
201
|
+
"minimum": -1440,
|
|
202
|
+
"maximum": 1440
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"required": [
|
|
206
|
+
"hostname",
|
|
207
|
+
"platform",
|
|
208
|
+
"utcOffset"
|
|
209
|
+
]
|
|
171
210
|
}
|
|
172
211
|
}
|
|
173
212
|
}
|