n2n-nexus 0.9.2 → 0.9.4
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 +45 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +61 -241
- package/SECURITY.md +4 -1
- package/build/cli/doctor.js +8 -7
- package/build/cli/hook.js +46 -18
- package/build/cli/install.js +87 -14
- package/build/cli/title.js +126 -0
- package/build/contract.js +22 -2
- package/build/index.js +4 -3
- package/build/server/instance-id.js +2 -2
- package/build/server/nexus.js +6 -2
- package/contract/errors.json +2 -2
- package/contract/hook.json +20 -3
- package/contract/tools.json +565 -17
- package/docs/ARCHITECTURE.md +16 -66
- package/docs/ARCHITECTURE_zh.md +18 -68
- package/docs/ASSISTANT_GUIDE.md +41 -13
- package/docs/ASSISTANT_GUIDE_zh.md +40 -14
- package/docs/CHANGELOG_zh.md +626 -0
- package/docs/CONTRIBUTING_zh.md +51 -0
- package/docs/INSTALL.md +146 -0
- package/docs/INSTALL_zh.md +146 -0
- package/docs/README_zh.md +71 -310
- package/docs/SECURITY_zh.md +40 -0
- package/docs/TOOLS_REFERENCE.md +91 -30
- package/docs/TOOLS_REFERENCE_zh.md +480 -0
- package/docs/USER_GUIDE.md +142 -0
- package/docs/USER_GUIDE_zh.md +142 -0
- package/llms.txt +3 -33
- package/package.json +10 -2
package/build/cli/install.js
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
* Dock do not read the shell PATH. The `node` found on PATH is preferred when it
|
|
10
10
|
* is the running binary, because process.execPath is the resolved, versioned
|
|
11
11
|
* location (nvm, Homebrew Cellar, ...) that disappears when Node is upgraded.
|
|
12
|
+
* The hooks run this installation's entry script; the MCP server runs
|
|
13
|
+
* `n2n-nexus@latest` through npm's npx with that Node, so every new
|
|
14
|
+
* conversation starts the latest published client.
|
|
12
15
|
*/
|
|
13
16
|
import { spawnSync } from "node:child_process";
|
|
14
17
|
import fs from "node:fs";
|
|
15
18
|
import os from "node:os";
|
|
16
19
|
import path from "node:path";
|
|
17
|
-
import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient } from "../contract.js";
|
|
20
|
+
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";
|
|
18
21
|
import { pkg } from "../config/index.js";
|
|
19
22
|
import { spawnClientSync } from "./client-command.js";
|
|
20
23
|
import { entryScript } from "./entry.js";
|
|
@@ -27,6 +30,8 @@ export class InstallError extends Error {
|
|
|
27
30
|
}
|
|
28
31
|
/** Path segments of Node installs that are replaced on upgrade. */
|
|
29
32
|
export const VERSIONED_NODE_DIR = /[/\\]Cellar[/\\]|[/\\]\.nvm[/\\]versions[/\\]|[/\\]node-v\d|[/\\]fnm[/\\]|[/\\]fnm_multishells[/\\]|[/\\]\.volta[/\\]|[/\\]\.asdf[/\\]installs[/\\]/;
|
|
33
|
+
/** The package the MCP server command starts through npx. */
|
|
34
|
+
export const MCP_PACKAGE = `${pkg.name}@latest`;
|
|
30
35
|
/** fnm puts a per-shell symlink directory first on PATH; it is gone when the shell ends. */
|
|
31
36
|
const PER_SHELL_NODE_DIR = /[/\\]fnm_multishells[/\\]/;
|
|
32
37
|
function sameFile(a, b) {
|
|
@@ -52,6 +57,49 @@ export function stableNodePath(env = process.env) {
|
|
|
52
57
|
}
|
|
53
58
|
return process.execPath;
|
|
54
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* What the MCP server command needs from `node`: npm's npx script, run as `"<node>" "<npx>"`, and `bin`, a folder
|
|
62
|
+
* whose `node` (`node.exe`) is that Node, put first on the server's PATH. npx is the `npx` link next to the Node
|
|
63
|
+
* (Unix; kept unresolved, like the Node path, when it leads to npx-cli.js) or `node_modules/npm/bin/npx-cli.js` next
|
|
64
|
+
* to it (Windows), else the same next to the resolved Node, else next to the binary the Node runs (a version
|
|
65
|
+
* manager's shim reports it as process.execPath).
|
|
66
|
+
*/
|
|
67
|
+
export function npxFor(node) {
|
|
68
|
+
const nearby = (file) => {
|
|
69
|
+
const dir = path.dirname(file);
|
|
70
|
+
const candidate = process.platform === "win32" ? path.join(dir, "node_modules", "npm", "bin", "npx-cli.js") : path.join(dir, "npx");
|
|
71
|
+
try {
|
|
72
|
+
return fs.realpathSync(candidate).endsWith(".js") ? candidate : undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const named = (file) => /^node(\.exe)?$/i.test(path.basename(file));
|
|
79
|
+
let resolved = node;
|
|
80
|
+
try {
|
|
81
|
+
resolved = fs.realpathSync(node);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// checkNodePath / stableNodePath already made sure the Node exists
|
|
85
|
+
}
|
|
86
|
+
let npx = nearby(node) ?? nearby(resolved);
|
|
87
|
+
let bin = [node, resolved].find(named);
|
|
88
|
+
if (!npx || !bin) {
|
|
89
|
+
const probe = spawnSync(node, ["-p", "process.execPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
90
|
+
const runs = probe.status === 0 ? probe.stdout.trim() : "";
|
|
91
|
+
if (runs) {
|
|
92
|
+
npx ??= nearby(runs);
|
|
93
|
+
if (named(runs))
|
|
94
|
+
bin ??= runs;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!npx)
|
|
98
|
+
throw new InstallError(`npm's npx was not found next to ${node}; install with --node <path> pointing at a Node.js that includes npm.`);
|
|
99
|
+
if (!bin)
|
|
100
|
+
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.`);
|
|
101
|
+
return { npx, bin: path.dirname(bin) };
|
|
102
|
+
}
|
|
55
103
|
export function parseVersion(text) {
|
|
56
104
|
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
|
|
57
105
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
@@ -162,7 +210,11 @@ function removeOurEntries(config, client, file) {
|
|
|
162
210
|
function addOurEntries(config, client, command, commandWindows) {
|
|
163
211
|
const hooks = (config.hooks ??= {});
|
|
164
212
|
for (const event of EVENTS[client]) {
|
|
165
|
-
|
|
213
|
+
// Codex clamps a SessionEnd hook timeout above 3 s (and warns in its settings), so ask for 3 s there.
|
|
214
|
+
const timeout = event === "Stop" ? HOOK_STOP_TIMEOUT_SEC
|
|
215
|
+
: client === "codex" && event === "SessionEnd" ? CODEX_SESSION_END_TIMEOUT_SEC
|
|
216
|
+
: HOOK_TIMEOUT_SEC;
|
|
217
|
+
const entry = { type: "command", command, timeout };
|
|
166
218
|
if (commandWindows)
|
|
167
219
|
entry.commandWindows = commandWindows;
|
|
168
220
|
const group = client === "claude" && event === "PreToolUse"
|
|
@@ -213,10 +265,18 @@ function mcpRemoveArgs(client) {
|
|
|
213
265
|
? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
|
|
214
266
|
: ["mcp", "remove", MCP_SERVER_NAME];
|
|
215
267
|
}
|
|
216
|
-
|
|
217
|
-
|
|
268
|
+
/**
|
|
269
|
+
* `<client> mcp add` for `"<node>" "<npx>" -y n2n-nexus@latest mcp`. PATH starts with `bin` (see npxFor): npx starts
|
|
270
|
+
* the package's bin, `#!/usr/bin/env node` (Unix) or a .cmd shim running `node` (Windows), which looks Node up on
|
|
271
|
+
* PATH, and a GUI client started from the Dock has none there. The rest is the system folders.
|
|
272
|
+
*/
|
|
273
|
+
function mcpAddArgs(client, node, { npx, bin }, endpoint) {
|
|
274
|
+
const flag = client === "claude" ? "-e" : "--env";
|
|
275
|
+
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
|
276
|
+
const dirs = process.platform === "win32" ? [bin, path.join(systemRoot, "System32"), systemRoot] : [bin, "/usr/bin", "/bin"];
|
|
277
|
+
const vars = [...(endpoint ? [`NEXUS_ENDPOINT=${endpoint}`] : []), `PATH=${dirs.join(process.platform === "win32" ? ";" : ":")}`];
|
|
218
278
|
const scope = client === "claude" ? ["--scope", "user"] : [];
|
|
219
|
-
return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...
|
|
279
|
+
return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...vars.flatMap((v) => [flag, v]), "--", node, npx, "-y", MCP_PACKAGE, "mcp"];
|
|
220
280
|
}
|
|
221
281
|
/**
|
|
222
282
|
* Run `<client> <args>` and throw like execFileSync when it fails. On Windows
|
|
@@ -233,7 +293,9 @@ function runClient(client, args, stdio) {
|
|
|
233
293
|
/**
|
|
234
294
|
* Run `<client> mcp remove`. A failure is ignored only when `<client> mcp get`
|
|
235
295
|
* shows the server is not registered (never installed, or already removed);
|
|
236
|
-
* otherwise the removal really failed and we stop before `mcp add`.
|
|
296
|
+
* otherwise the removal really failed and we stop before `mcp add`. On
|
|
297
|
+
* uninstall, a client that cannot be run skips the step with a note on how to
|
|
298
|
+
* remove the registration that is still there.
|
|
237
299
|
*/
|
|
238
300
|
function removeMcpServer(client, action, hooksRemoved) {
|
|
239
301
|
const removeArgs = mcpRemoveArgs(client);
|
|
@@ -255,6 +317,13 @@ function removeMcpServer(client, action, hooksRemoved) {
|
|
|
255
317
|
}
|
|
256
318
|
if (!registered)
|
|
257
319
|
return;
|
|
320
|
+
if (action === "uninstall" && error.code === "ENOENT") {
|
|
321
|
+
const file = mcpConfigFile(client);
|
|
322
|
+
const what = client === "claude" ? `the "${MCP_SERVER_NAME}" entry under "mcpServers"` : `the [mcp_servers.${MCP_SERVER_NAME}] table`;
|
|
323
|
+
process.stdout.write(`Skipped "${client} ${removeArgs.join(" ")}": the "${client}" command could not be run (not on PATH, or not runnable without a shell). `
|
|
324
|
+
+ `The MCP server is still registered in ${file}; run that command once ${client} can be run, or delete ${what} from ${file}.\n`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
258
327
|
const failure = error;
|
|
259
328
|
const stderr = typeof failure.stderr === "string" ? failure.stderr.trim() : "";
|
|
260
329
|
const detail = stderr || (typeof failure.status === "number" ? `exit code ${failure.status}` : failure.message);
|
|
@@ -270,13 +339,17 @@ export function runInstall(options) {
|
|
|
270
339
|
const entry = options.entry ?? entryScript();
|
|
271
340
|
try {
|
|
272
341
|
if (action === "install" && /[/\\]_npx[/\\]/.test(entry)) {
|
|
273
|
-
throw new InstallError(`n2n-nexus is running from an npx cache (${entry}), which can be cleared at any time. Install it first: npm i -g
|
|
342
|
+
throw new InstallError(`n2n-nexus is running from an npx cache (${entry}), which can be cleared at any time. Install it first: npm i -g ${MCP_PACKAGE}`);
|
|
274
343
|
}
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
344
|
+
// Uninstall does not need the client: our hooks are removed from its config file either way.
|
|
345
|
+
if (action === "install") {
|
|
346
|
+
const probe = spawnClientSync(client, ["--version"], { stdio: "ignore" });
|
|
347
|
+
if (probe.error || probe.status !== 0) {
|
|
348
|
+
throw new InstallError(`The "${client}" command was not found on PATH; install ${client === "claude" ? "Claude Code" : "Codex"} first.`);
|
|
349
|
+
}
|
|
278
350
|
}
|
|
279
351
|
const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
|
|
352
|
+
const npm = node ? npxFor(node) : undefined;
|
|
280
353
|
const file = configFile(client);
|
|
281
354
|
const config = readConfig(file);
|
|
282
355
|
const removed = removeOurEntries(config, client, file);
|
|
@@ -292,13 +365,13 @@ export function runInstall(options) {
|
|
|
292
365
|
process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
|
|
293
366
|
}
|
|
294
367
|
removeMcpServer(client, action, removed);
|
|
295
|
-
if (action === "install" && node) {
|
|
296
|
-
const addArgs = mcpAddArgs(client, node,
|
|
368
|
+
if (action === "install" && node && npm) {
|
|
369
|
+
const addArgs = mcpAddArgs(client, node, npm, endpoint);
|
|
297
370
|
runClient(client, addArgs, ["ignore", "ignore", "inherit"]);
|
|
298
371
|
process.stdout.write(`Ran: ${client} ${addArgs.join(" ")}\n`);
|
|
299
372
|
process.stdout.write(`Node: ${node}\n`);
|
|
300
|
-
if (VERSIONED_NODE_DIR.test(node)) {
|
|
301
|
-
process.stdout.write(`Note: this Node path contains a version directory that an upgrade may remove. After upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.\n`);
|
|
373
|
+
if (VERSIONED_NODE_DIR.test(node) || VERSIONED_NODE_DIR.test(npm.npx)) {
|
|
374
|
+
process.stdout.write(`Note: this Node or npx path contains a version directory that an upgrade may remove. After upgrading Node, run "n2n-nexus install ${client}" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.\n`);
|
|
302
375
|
}
|
|
303
376
|
if (client === "codex") {
|
|
304
377
|
process.stdout.write("Next: open Codex and run /hooks to trust the n2n-nexus hooks; until then they do not run.\n");
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation's title as its client shows it, which the hook command adds
|
|
3
|
+
* to the input it forwards as `title`. Neither source is documented, so every
|
|
4
|
+
* failure (missing file, unexpected format, locked database, time budget
|
|
5
|
+
* spent) yields undefined and the hook goes on without a title.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code: the transcript (`transcript_path`, JSONL, can be very large)
|
|
8
|
+
* gets `{"type":"custom-title","customTitle":…}` records when the user renames
|
|
9
|
+
* the conversation and `{"type":"ai-title","aiTitle":…}` records for the
|
|
10
|
+
* automatic title, appended again and again; only its last and, failing that,
|
|
11
|
+
* first TRANSCRIPT_CHUNK_BYTES are read. Codex: `threads.name` (set by the
|
|
12
|
+
* user) or else `threads.title` in the newest `$CODEX_HOME/state_<N>.sqlite`.
|
|
13
|
+
*/
|
|
14
|
+
import { open, readdir } from "node:fs/promises";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { MAX_TITLE_CHARS } from "../contract.js";
|
|
18
|
+
/** How much of the transcript's end, and then of its start, is searched for a title. */
|
|
19
|
+
const TRANSCRIPT_CHUNK_BYTES = 256 * 1024;
|
|
20
|
+
/** Reading the title may not hold up the hook for longer than this. */
|
|
21
|
+
const TITLE_BUDGET_MS = 50;
|
|
22
|
+
/** Trimmed first line, at most MAX_TITLE_CHARS characters; undefined when nothing is left. */
|
|
23
|
+
export function cleanTitle(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
const line = value.trim().split(/\r\n|\r|\n/)[0].trim();
|
|
27
|
+
const chars = Array.from(line);
|
|
28
|
+
const title = chars.length > MAX_TITLE_CHARS ? chars.slice(0, MAX_TITLE_CHARS).join("").trimEnd() : line;
|
|
29
|
+
return title || undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The last custom title in `text`, or else the last automatic one. */
|
|
32
|
+
function titleInJsonl(text) {
|
|
33
|
+
let custom;
|
|
34
|
+
let ai;
|
|
35
|
+
for (const line of text.split("\n")) {
|
|
36
|
+
if (!line.includes("-title"))
|
|
37
|
+
continue;
|
|
38
|
+
let record;
|
|
39
|
+
try {
|
|
40
|
+
record = JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue; // the chunk's cut-off first or last line
|
|
44
|
+
}
|
|
45
|
+
if (typeof record !== "object" || record === null)
|
|
46
|
+
continue;
|
|
47
|
+
const { type, customTitle, aiTitle } = record;
|
|
48
|
+
if (type === "custom-title")
|
|
49
|
+
custom = cleanTitle(customTitle) ?? custom;
|
|
50
|
+
else if (type === "ai-title")
|
|
51
|
+
ai = cleanTitle(aiTitle) ?? ai;
|
|
52
|
+
}
|
|
53
|
+
return custom ?? ai;
|
|
54
|
+
}
|
|
55
|
+
async function claudeTitle(transcriptPath) {
|
|
56
|
+
if (typeof transcriptPath !== "string" || !transcriptPath)
|
|
57
|
+
return undefined;
|
|
58
|
+
const file = await open(transcriptPath, "r");
|
|
59
|
+
try {
|
|
60
|
+
const { size } = await file.stat();
|
|
61
|
+
const read = async (position, length) => {
|
|
62
|
+
const buffer = Buffer.alloc(length);
|
|
63
|
+
const { bytesRead } = await file.read(buffer, 0, length, position);
|
|
64
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
65
|
+
};
|
|
66
|
+
const tailStart = Math.max(0, size - TRANSCRIPT_CHUNK_BYTES);
|
|
67
|
+
const title = titleInJsonl(await read(tailStart, size - tailStart));
|
|
68
|
+
if (title !== undefined || tailStart === 0)
|
|
69
|
+
return title;
|
|
70
|
+
return titleInJsonl(await read(0, TRANSCRIPT_CHUNK_BYTES));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await file.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** node:sqlite without the ExperimentalWarning older Node versions print on stderr when it loads. */
|
|
77
|
+
async function loadSqlite() {
|
|
78
|
+
const emitWarning = process.emitWarning;
|
|
79
|
+
process.emitWarning = function (warning, ...args) {
|
|
80
|
+
const type = typeof args[0] === "string" ? args[0] : args[0]?.type ?? (warning instanceof Error ? warning.name : undefined);
|
|
81
|
+
const message = warning instanceof Error ? warning.message : String(warning);
|
|
82
|
+
if (type === "ExperimentalWarning" && message.includes("SQLite"))
|
|
83
|
+
return;
|
|
84
|
+
return emitWarning.call(process, warning, ...args);
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
return await import("node:sqlite");
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
process.emitWarning = emitWarning;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function codexTitle(sessionId) {
|
|
94
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
95
|
+
return undefined;
|
|
96
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
97
|
+
let newest;
|
|
98
|
+
for (const name of await readdir(home)) {
|
|
99
|
+
const match = /^state_(\d+)\.sqlite$/.exec(name);
|
|
100
|
+
if (match && (newest === undefined || Number(match[1]) > newest.n))
|
|
101
|
+
newest = { n: Number(match[1]), name };
|
|
102
|
+
}
|
|
103
|
+
if (!newest)
|
|
104
|
+
return undefined;
|
|
105
|
+
const { DatabaseSync } = await loadSqlite();
|
|
106
|
+
const db = new DatabaseSync(path.join(home, newest.name), { readOnly: true });
|
|
107
|
+
try {
|
|
108
|
+
const row = db.prepare("SELECT name, title FROM threads WHERE id = ?").get(sessionId);
|
|
109
|
+
return row ? cleanTitle(row.name) ?? cleanTitle(row.title) : undefined;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The title of the conversation `input` (the hook's stdin) belongs to, or undefined; never throws. */
|
|
116
|
+
export async function readTitle(client, input) {
|
|
117
|
+
let timer;
|
|
118
|
+
const budget = new Promise((resolve) => { timer = setTimeout(resolve, TITLE_BUDGET_MS, undefined); });
|
|
119
|
+
const read = client === "claude" ? claudeTitle(input.transcript_path) : codexTitle(input.session_id);
|
|
120
|
+
try {
|
|
121
|
+
return await Promise.race([read.catch(() => undefined), budget]);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/build/contract.js
CHANGED
|
@@ -24,20 +24,28 @@ export const CLIENT_INFO_NAMES = contract.clientInfoNames;
|
|
|
24
24
|
export const CLAUDE_TOOL_USE_META_KEY = contract.meta.claudeToolUseKey;
|
|
25
25
|
export const CODEX_SESSION_META_KEY = contract.meta.codexSessionKey;
|
|
26
26
|
/**
|
|
27
|
-
* Hook stdin fields the host uses. The hook command forwards only these
|
|
27
|
+
* Hook stdin fields the host uses. The hook command forwards only these (plus
|
|
28
|
+
* `title` on SessionStart, UserPromptSubmit and Stop, see cli/title.ts):
|
|
28
29
|
* PostToolUse stdin also carries the whole tool input and response, which can
|
|
29
30
|
* be large and runs on every tool call.
|
|
30
31
|
*/
|
|
31
32
|
export const HOOK_INPUT_FIELDS = contract.hookInputFields;
|
|
32
33
|
/**
|
|
33
34
|
* Pseudo hook event of the background wait (`n2n-nexus hook <client> --wait`):
|
|
34
|
-
* started when a turn ends, it waits and returns `{ wake: text }` when
|
|
35
|
+
* started when a turn ends, it waits and returns `{ wake: text, dispatchId }` when
|
|
35
36
|
* something new arrives for the session, `{ timeout: true }` when the time
|
|
36
37
|
* runs out (the client asks again), or null when the session became active
|
|
37
38
|
* again (the client stops). Claude delivers the text through an asyncRewake
|
|
38
39
|
* hook, Codex through `codex queue`.
|
|
39
40
|
*/
|
|
40
41
|
export const BACKGROUND_WAIT_EVENT = contract.backgroundWaitEvent;
|
|
42
|
+
/**
|
|
43
|
+
* Pseudo hook event with which the Codex background wait reports whether
|
|
44
|
+
* `codex queue` took a wake: input `{ session_id, dispatch_id, ok }`. On
|
|
45
|
+
* `ok: false` the host undoes the wake, so its messages and notices wake the
|
|
46
|
+
* session again.
|
|
47
|
+
*/
|
|
48
|
+
export const WAKE_RESULT_EVENT = contract.wakeResultEvent;
|
|
41
49
|
/** HTTP timeout of the hook command: a listening Stop may wait long, every other event is quick. */
|
|
42
50
|
export const HOOK_STOP_REQUEST_TIMEOUT_MS = contract.timing.hookStopRequestTimeoutMs;
|
|
43
51
|
export const HOOK_REQUEST_TIMEOUT_MS = contract.timing.hookRequestTimeoutMs;
|
|
@@ -46,6 +54,18 @@ export const HOOK_STOP_TIMEOUT_SEC = contract.timing.hookStopTimeoutSec;
|
|
|
46
54
|
/** `timeout` (seconds) of the background wait hook; a conversation idle for longer is not woken. */
|
|
47
55
|
export const WAIT_HOOK_TIMEOUT_SEC = contract.timing.waitHookTimeoutSec;
|
|
48
56
|
export const HOOK_TIMEOUT_SEC = contract.timing.hookTimeoutSec;
|
|
57
|
+
/** `timeout` (seconds) of the Codex SessionEnd hook: Codex clamps a longer one to 3 s and warns about it. */
|
|
58
|
+
export const CODEX_SESSION_END_TIMEOUT_SEC = contract.timing.codexSessionEndTimeoutSec;
|
|
59
|
+
/** HTTP timeout of a SessionEnd hook, which is not resent after a timeout so it ends within CODEX_SESSION_END_TIMEOUT_SEC. */
|
|
60
|
+
export const SESSION_END_REQUEST_TIMEOUT_MS = contract.timing.sessionEndRequestTimeoutMs;
|
|
61
|
+
/** After a failed `codex queue`, the Codex wait asks again after this delay, doubled on each failure up to the maximum. */
|
|
62
|
+
export const WAKE_RETRY_FIRST_DELAY_MS = contract.timing.wakeRetryFirstDelayMs;
|
|
63
|
+
export const WAKE_RETRY_MAX_DELAY_MS = contract.timing.wakeRetryMaxDelayMs;
|
|
64
|
+
/**
|
|
65
|
+
* The hook command adds the conversation's title, as its client shows it, to
|
|
66
|
+
* the input it forwards as `title` (at most this many characters).
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_TITLE_CHARS = contract.limits.maxTitleChars;
|
|
49
69
|
/** Name the MCP server is installed under; Claude's PreToolUse matcher depends on it. */
|
|
50
70
|
export const MCP_SERVER_NAME = contract.mcpServerName;
|
|
51
71
|
export const CLAUDE_PRE_TOOL_MATCHER = `mcp__${MCP_SERVER_NAME}__.*`;
|
package/build/index.js
CHANGED
|
@@ -16,9 +16,10 @@ USAGE:
|
|
|
16
16
|
n2n-nexus uninstall <claude|codex>
|
|
17
17
|
Remove the hooks and the MCP server installed by install
|
|
18
18
|
n2n-nexus doctor [claude|codex]
|
|
19
|
-
Check that the Node path and entry script in the installed
|
|
20
|
-
MCP
|
|
21
|
-
contract version; exit 1 and say how to
|
|
19
|
+
Check that the Node path and entry script in the installed hooks and
|
|
20
|
+
the Node path and npx in the MCP command exist and run, and that the
|
|
21
|
+
host answers with the same contract version; exit 1 and say how to
|
|
22
|
+
fix them when not
|
|
22
23
|
n2n-nexus hook <claude|codex> [--endpoint <url>] [--wait]
|
|
23
24
|
Forward a client hook event to the host (run by the hooks)
|
|
24
25
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
/**
|
|
4
|
-
* Identity of this MCP adapter as seen by the daemon. Read cursors
|
|
5
|
-
*
|
|
4
|
+
* Identity of this MCP adapter as seen by the daemon. Read cursors are
|
|
5
|
+
* keyed on it, so it has to
|
|
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.
|
package/build/server/nexus.js
CHANGED
|
@@ -12,10 +12,14 @@ 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
14
|
"possibly on other machines). Direct messages: call peers to see who is online, send to message a session, " +
|
|
15
|
-
"read to read messages sent to you and reply to answer one
|
|
15
|
+
"read to read messages sent to you, carry out what they ask and reply to answer one (set expectsReply and wake false " +
|
|
16
|
+
"on a message that needs no answer, and send no plain acknowledgements); listen waits for messages after each turn. " +
|
|
16
17
|
"Direct messaging needs the n2n-nexus hooks. Separately, send_message and read_messages are the shared " +
|
|
17
18
|
"global chat and meetings, not direct messages. Session ids in peers (claude:<id>, codex:<id>) are what send " +
|
|
18
|
-
"takes; the
|
|
19
|
+
"takes; with the hooks, chat and meetings also record senders and unread cursors per session id " +
|
|
20
|
+
"(without them, per MCP adapter instance id, mcp-…). join_meeting makes new messages of a meeting wake you " +
|
|
21
|
+
"(mode active) or show at your next prompt (async) until leave_meeting or the meeting ends; a draft " +
|
|
22
|
+
"(send_message draft) is decided with stance, and the meeting closes once its required approvers endorse it. " +
|
|
19
23
|
"Projects, global docs, the strategy document and tasks are shared through the other tools.";
|
|
20
24
|
/**
|
|
21
25
|
* MCP adapter in front of the daemon. It holds no session of its own: each
|
package/contract/errors.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
2
|
+
"contractVersion": 2,
|
|
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": 2,
|
|
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": 2,
|
|
3
3
|
"clients": [
|
|
4
4
|
"claude",
|
|
5
5
|
"codex"
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"reason"
|
|
25
25
|
],
|
|
26
26
|
"backgroundWaitEvent": "BackgroundWait",
|
|
27
|
+
"wakeResultEvent": "WakeResult",
|
|
27
28
|
"agentStatuses": [
|
|
28
29
|
"working",
|
|
29
30
|
"idle",
|
|
@@ -37,6 +38,10 @@
|
|
|
37
38
|
"hookStopTimeoutSec": 600,
|
|
38
39
|
"waitHookTimeoutSec": 3600,
|
|
39
40
|
"hookTimeoutSec": 10,
|
|
41
|
+
"codexSessionEndTimeoutSec": 3,
|
|
42
|
+
"sessionEndRequestTimeoutMs": 1500,
|
|
43
|
+
"wakeRetryFirstDelayMs": 1000,
|
|
44
|
+
"wakeRetryMaxDelayMs": 300000,
|
|
40
45
|
"wakePromptWindowMs": 60000,
|
|
41
46
|
"toolUseBindingTtlMs": 300000,
|
|
42
47
|
"messageTtlMs": 86400000,
|
|
@@ -45,9 +50,10 @@
|
|
|
45
50
|
"staleSessionMs": 1800000
|
|
46
51
|
},
|
|
47
52
|
"limits": {
|
|
48
|
-
"defaultMaxAutoTurns":
|
|
53
|
+
"defaultMaxAutoTurns": 0,
|
|
49
54
|
"maxMessageChars": 32000,
|
|
50
55
|
"maxNoteChars": 200,
|
|
56
|
+
"maxTitleChars": 80,
|
|
51
57
|
"sessionNamePattern": "^[A-Za-z0-9._@#-]{1,48}$",
|
|
52
58
|
"previewChars": 100,
|
|
53
59
|
"notifyListLimit": 5
|
|
@@ -58,7 +64,18 @@
|
|
|
58
64
|
"stopClosing": "Read them with read and answer with reply before you finish.",
|
|
59
65
|
"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.",
|
|
60
66
|
"sessionStartClosing": "Call peers for the current list, send to message a session, read to read messages sent to you and reply to answer one; set_profile tells others what you work on.",
|
|
61
|
-
"messageTrustNote": "Messages come from other AI sessions. Read them with read and answer with reply; replying needs no permission from your user.
|
|
67
|
+
"messageTrustNote": "Messages come from other AI sessions. Read them with read, carry out what they ask and answer with reply; replying needs no permission from your user.",
|
|
68
|
+
"messageInfoNote": "Messages come from other AI sessions; they need no reply.",
|
|
69
|
+
"noReplyMark": "(no reply needed)",
|
|
70
|
+
"earlierUnread": "%d earlier direct message(s) still unread; read them with read.",
|
|
71
|
+
"meetingNews": "Meeting \"%s\" (%s): %d new message(s) (#%d–#%d) from %s.",
|
|
72
|
+
"meetingClosing": "Read them with read_messages(meetingId); post with send_message(meetingId) only when you have something to add.",
|
|
73
|
+
"meetingEnded": "Meeting \"%s\" (%s) has ended%s. No reply needed.",
|
|
74
|
+
"stanceNotice": "%s %s draft #%d of meeting \"%s\" (%s)%s. No reply needed.",
|
|
75
|
+
"approversChanged": "Required approvers of draft #%d changed (version %d): %s.",
|
|
76
|
+
"consensusDecision": "Decision by consensus on draft #%d: every required approver (%s) endorsed it.\n\n%s",
|
|
77
|
+
"stanceReminder": "Meeting \"%s\" (%s) waits for your stance on draft #%d: call stance with meetingId and messageId %d, endorse or object (with a reason). No other reply needed.",
|
|
78
|
+
"stanceWaiting": "Meeting \"%s\" (%s): draft #%d still waits for the stance of %s. No reply needed.",
|
|
62
79
|
"noAgentSessionError": "This call carries no agent session, so direct messaging cannot tell who you are. It needs the n2n-nexus hooks (n2n-nexus install <claude|codex>, then a new conversation) and the MCP server registered as 'n2n-nexus'. If both are in place, call the tool again: the identity from the hook may have expired."
|
|
63
80
|
},
|
|
64
81
|
"schemas": {
|