n2n-nexus 0.12.6 → 0.13.1
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 +16 -0
- package/README.md +5 -5
- package/SECURITY.md +1 -1
- package/build/cli/client-command.js +1 -1
- package/build/cli/client-dirs.js +3 -1
- package/build/cli/client-process.js +25 -0
- package/build/cli/doctor.js +71 -16
- package/build/cli/hook.js +8 -2
- package/build/cli/install.js +56 -19
- package/build/cli/mcp-config.js +10 -6
- package/build/cli/pi.js +247 -0
- package/build/contract.js +1 -1
- package/build/index.js +5 -5
- package/build/server/nexus.js +17 -3
- package/contract/hook.json +51 -5
- package/contract/tools.json +11 -14
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/ARCHITECTURE_zh.md +3 -3
- package/docs/ASSISTANT_GUIDE.md +2 -2
- package/docs/ASSISTANT_GUIDE_zh.md +2 -2
- package/docs/CHANGELOG_zh.md +16 -0
- package/docs/INSTALL.md +9 -7
- package/docs/INSTALL_zh.md +9 -7
- package/docs/README_zh.md +5 -5
- package/docs/SECURITY_zh.md +1 -1
- package/docs/TOOLS_REFERENCE.md +6 -6
- package/docs/TOOLS_REFERENCE_zh.md +6 -6
- package/docs/USER_GUIDE.md +5 -5
- package/docs/USER_GUIDE_zh.md +5 -5
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project will be documented in this file.
|
|
6
6
|
|
|
7
|
+
## [0.13.1] - 2026-11-15
|
|
8
|
+
|
|
9
|
+
Upgrade the host; the client needs nothing (contract version 5). Start new conversations afterwards: one started before keeps the old `peers` definition, whose `includeOffline` the host now ignores.
|
|
10
|
+
|
|
11
|
+
### Removed
|
|
12
|
+
|
|
13
|
+
- `peers` no longer takes `includeOffline` and never lists offline sessions: AI work starts new conversations far more often than it resumes old ones, so listing the ones that ended in the last 24 hours to leave them a message was little used. A message sent to an offline session by its session ID still waits for it (a reply to a sender that ended its conversation, a session that only looked offline after 30 minutes without activity). The contract's `timing.peerWindowMs` is gone with it.
|
|
14
|
+
|
|
15
|
+
## [0.13.0] - 2026-11-15
|
|
16
|
+
|
|
17
|
+
Upgrade the host and the client together for pi (the contract version stays 5: an older host answers a pi hook with 400, which the hook ignores). The database is upgraded in place: keep `nexus.db`. An older host refuses the upgraded database. After upgrading the host, start new Claude Code and Codex conversations: one started before keeps the old tool definitions, in which a session's client is only claude or codex, so its `peers` fails as soon as a pi session is listed (with `includeOffline`, also an offline one).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- pi as a client: `n2n-nexus install pi` writes a pi extension (`~/.pi/agent/extensions/n2n-nexus.ts`, or under `$PI_CODING_AGENT_DIR`) that runs `n2n-nexus hook pi` on pi's session start, prompt, tool result, end of run and shutdown, continues a run when messages arrive during it and wakes an idle pi session through the background wait, and registers the MCP server with `pi mcp add`; `uninstall pi` removes both and `doctor pi` checks them. pi's MCP tool calls carry no session, so the MCP server started by pi names the processes above it (pi starts it through npx and a shell) and its machine, and the host attributes the call to the session the nearest pi process among them on that machine reported last.
|
|
22
|
+
|
|
7
23
|
## [0.12.6] - 2026-11-10
|
|
8
24
|
|
|
9
25
|
Upgrade the host and the client together (contract version 5), then run `n2n-nexus install <client>` again so the background wait hook gets its new timeout. The database is upgraded in place: keep `nexus.db`. An older host refuses the upgraded database.
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
|
|
|
53
53
|
|
|
54
54
|
**Quick summary**
|
|
55
55
|
|
|
56
|
-
- **Install**: the host `n2n-nexus-host` with Scoop / Homebrew / a Releases archive; the client with `npm i -g n2n-nexus@latest`, then `n2n-nexus install <claude|codex>`
|
|
56
|
+
- **Install**: the host `n2n-nexus-host` with Scoop / Homebrew / a Releases archive; the client with `npm i -g n2n-nexus@latest`, then `n2n-nexus install <claude|codex|pi>`
|
|
57
57
|
- **Protocol**: Model Context Protocol (MCP); the adapters reach the host over HTTP
|
|
58
58
|
- **Storage**: one SQLite database in the host's data directory
|
|
59
59
|
- **Good for**: multi-assistant coding sessions, project handoffs, shared decisions, meeting records, task tracking, project manifests
|
|
@@ -62,7 +62,7 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
|
|
|
62
62
|
## 🎯 Why use it?
|
|
63
63
|
|
|
64
64
|
- Coordinate multiple AI assistants working on the same project.
|
|
65
|
-
- Let Claude Code and
|
|
65
|
+
- Let Claude Code, Codex and pi 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
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.
|
|
67
67
|
- Share project manifests, decisions, and assets across IDEs and machines.
|
|
68
68
|
- Keep decisions, proposals, and updates in a local meeting and message log; old data is cleaned up after the retention you set.
|
|
@@ -87,13 +87,13 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
|
|
|
87
87
|
|
|
88
88
|
- **The host is the source of truth**: install it once and keep it running, as a tray app or as a background service.
|
|
89
89
|
- **MCP adapters are stateless**: each IDE starts an adapter (`n2n-nexus mcp`), which forwards tool calls to the host.
|
|
90
|
-
- **Every Claude Code and
|
|
90
|
+
- **Every Claude Code, Codex and pi conversation has its own identity**: client hooks (for pi, an extension) 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 (for pi, the pi process above the MCP server), never through model-supplied arguments.
|
|
91
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.
|
|
92
92
|
|
|
93
93
|
## 🚀 Quick start
|
|
94
94
|
|
|
95
95
|
1. Install and start the host, `n2n-nexus-host`, with Scoop, Homebrew or an archive from [Releases](https://github.com/n2ns/n2n-nexus/releases): [Installation guide, steps 1–2](docs/INSTALL.md#1-install-the-host).
|
|
96
|
-
2. Install the client and connect Claude Code or
|
|
96
|
+
2. Install the client and connect Claude Code, Codex or pi (`npm i -g n2n-nexus@latest`, then `n2n-nexus install claude`, `n2n-nexus install codex` or `n2n-nexus install pi`): [step 3](docs/INSTALL.md#3-install-the-client-for-claude-code-codex-and-pi).
|
|
97
97
|
3. Codex only: `install codex` trusts the n2n-nexus hooks in Codex; if it says it could not, trust them by hand: [step 4](docs/INSTALL.md#4-turn-on-the-codex-hooks-one-time-required).
|
|
98
98
|
|
|
99
99
|
Other MCP clients and several machines: see the [Installation guide](docs/INSTALL.md). Using it, troubleshooting and configuration: see the [User guide](docs/USER_GUIDE.md).
|
|
@@ -108,7 +108,7 @@ The host exposes 36 tools, grouped by purpose:
|
|
|
108
108
|
| **Projects & assets** | `search_projects`, `get_project`, `update_project`, `rename_project`, `sync_project_assets`, `upload_project_asset` | Find, read, update, and rename projects; sync manifests and internal docs; upload binary assets. |
|
|
109
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. |
|
|
110
110
|
| **Messaging** | `send_message`, `read_messages`, `list_events` | Post meeting or global messages; read them and the activity log. |
|
|
111
|
-
| **Direct messages** | `peers`, `send`, `read`, `reply`, `broadcast`, `listen`, `set_profile` | Find other Claude Code / Codex conversations, message them, read and answer what arrives, notify every online conversation, wait for messages, and name yourself. |
|
|
111
|
+
| **Direct messages** | `peers`, `send`, `read`, `reply`, `broadcast`, `listen`, `set_profile` | Find other Claude Code / Codex / pi conversations, message them, read and answer what arrives, notify every online conversation, wait for messages, and name yourself. |
|
|
112
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. |
|
|
113
113
|
| **Tasks** | `create_task`, `get_task`, `list_tasks`, `update_task`, `cancel_task` | Create, update and track tasks that assistants manage themselves. |
|
|
114
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. |
|
package/SECURITY.md
CHANGED
|
@@ -28,7 +28,7 @@ n2n-nexus runs a local HTTP host, `n2n-nexus-host`, that stores coordination dat
|
|
|
28
28
|
Key boundaries to be aware of:
|
|
29
29
|
|
|
30
30
|
- **Unsigned binaries**: The `n2n-nexus-host` release binaries are not code-signed yet. Verify a download with the release's `SHA256SUMS` and `gh attestation verify <file> --repo n2ns/n2n-nexus` (build provenance), or install through Homebrew, which builds from source.
|
|
31
|
-
- **Local trust model**: Every process running as the same operating-system user is trusted. The host has no authentication; such a process can call it directly or read and write `nexus.db` and the client transcripts under `~/.claude` and `~/.
|
|
31
|
+
- **Local trust model**: Every process running as the same operating-system user is trusted. The host has no authentication; such a process can call it directly or read and write `nexus.db` and the client transcripts under `~/.claude`, `~/.codex` and `~/.pi`. Conversation IDs, Claude tool-use bindings and pi process IDs route direct messages between conversations; they are not credentials, so an AI session with shell access can act as another conversation of the same user. Checks inside the host cannot prevent that while the data stays readable to that user. The barrier that matters is the client's permission to run commands, which the user controls. To isolate agents from each other, run them as different operating-system users, each with its own host and data directory.
|
|
32
32
|
|
|
33
33
|
- **Host network exposure**: The host binds to `127.0.0.1` by default. LAN access (the tray's **Allow LAN access**, `"host": "0.0.0.0"` in `settings.json`, or `--host 0.0.0.0`) exposes it to your local network, and it then answers LAN discovery on UDP `discoveryPort`. There is no authentication layer in the open-source baseline — only expose the host beyond localhost on networks you control.
|
|
34
34
|
- **Browser requests**: The host has no authentication, so it refuses requests that carry a browser `Origin` header or a cross-site `Sec-Fetch-Site` header (403), requires `Content-Type: application/json` on POST (415) and caps request bodies at 16 MB (413). It also rejects any `Host` header that is not an IP literal, `localhost`, `host.docker.internal` or a name listed in `allowedHosts` in `settings.json`, which closes DNS-rebinding attacks where a page reaches the host as a same-origin request under the attacker's domain. MCP adapters and scripts are unaffected.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Run the `claude` / `codex` command without a shell. On Windows npm installs
|
|
2
|
+
* Run the `claude` / `codex` / `pi` command without a shell. On Windows npm installs
|
|
3
3
|
* them as `.cmd` shims, which Node cannot start without cmd.exe, and cmd.exe
|
|
4
4
|
* would parse the arguments again: a message text from another session could
|
|
5
5
|
* end a quoted argument and run commands, and a newline ends the command line.
|
package/build/cli/client-dirs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where Claude Code and
|
|
2
|
+
* Where Claude Code, Codex and pi keep their configuration. Node built-ins only:
|
|
3
3
|
* the hook command uses this to read a Codex conversation's title.
|
|
4
4
|
*/
|
|
5
5
|
import os from "node:os";
|
|
@@ -10,3 +10,5 @@ export const claudeConfigDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(
|
|
|
10
10
|
export const claudeJsonDir = () => process.env.CLAUDE_CONFIG_DIR || os.homedir();
|
|
11
11
|
/** Codex's hooks.json, config.toml and state databases: CODEX_HOME, else ~/.codex. */
|
|
12
12
|
export const codexHome = () => process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
13
|
+
/** pi's agent directory (extensions, mcp.json, settings.json): PI_CODING_AGENT_DIR, else ~/.pi/agent. */
|
|
14
|
+
export const piAgentDir = () => process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
@@ -47,6 +47,31 @@ function describe(pid) {
|
|
|
47
47
|
throw new Error(`no process ${pid}`);
|
|
48
48
|
return { name: name.toLowerCase(), ppid };
|
|
49
49
|
}
|
|
50
|
+
/** The most processes ancestorPids lists (contract/hook.json caller.clientPids maxItems). */
|
|
51
|
+
const MAX_ANCESTORS = 16;
|
|
52
|
+
/**
|
|
53
|
+
* This process's parent, its parent and so on up to (not including) init,
|
|
54
|
+
* nearest first; on Windows, or where a parent cannot be read, the list ends
|
|
55
|
+
* there. pi starts the MCP adapter through npx and a shell, so the pi process
|
|
56
|
+
* is one of them (the host finds it among them).
|
|
57
|
+
*/
|
|
58
|
+
export function ancestorPids() {
|
|
59
|
+
const pids = [process.ppid];
|
|
60
|
+
if (process.platform === "win32")
|
|
61
|
+
return pids;
|
|
62
|
+
try {
|
|
63
|
+
while (pids.length < MAX_ANCESTORS) {
|
|
64
|
+
const { ppid } = describe(pids[pids.length - 1]);
|
|
65
|
+
if (ppid <= 1 || pids.includes(ppid))
|
|
66
|
+
break;
|
|
67
|
+
pids.push(ppid);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Not readable: the processes found so far.
|
|
72
|
+
}
|
|
73
|
+
return pids;
|
|
74
|
+
}
|
|
50
75
|
/**
|
|
51
76
|
* The process that ran this hook, past the shells it went through: the
|
|
52
77
|
* nearest ancestor that is not a shell; undefined when it cannot be told.
|
package/build/cli/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n2n-nexus doctor [claude|codex]`: check that the Node path and the entry
|
|
3
|
-
* script in the hook commands, and the Node path and npm's npx script in the
|
|
2
|
+
* `n2n-nexus doctor [claude|codex|pi]`: check that the Node path and the entry
|
|
3
|
+
* script in the hook commands (for pi, in its extension file), and the Node path and npm's npx script in the
|
|
4
4
|
* MCP server command (`"<node>" "<npx>" -y n2n-nexus@latest mcp`), that
|
|
5
5
|
* `install` wrote still exist and run, that the host those commands talk to answers
|
|
6
6
|
* GET /health as n2n-nexus-host with this client's contract version, that
|
|
@@ -17,6 +17,7 @@ import { pkg } from "../config/index.js";
|
|
|
17
17
|
import { hookEndpoint, isOurs, ourCommands, parseHookCommand } from "./hook-command.js";
|
|
18
18
|
import { MCP_PACKAGE, VERSIONED_NODE_DIR, configFile, probeNode, readConfig } from "./install.js";
|
|
19
19
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
20
|
+
import { PI_EXTENSION_MARK, piExtensionFile, readPiExtension, readPiExtensionFile } from "./pi.js";
|
|
20
21
|
const ok = (text) => ({ ok: true, text });
|
|
21
22
|
const bad = (text) => ({ ok: false, text });
|
|
22
23
|
const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
|
|
@@ -92,7 +93,72 @@ async function doctorHosts(endpoints) {
|
|
|
92
93
|
* Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed.
|
|
93
94
|
* Adds the endpoints its hook and MCP commands use to `endpoints`, falling back to defaultEndpoint.
|
|
94
95
|
*/
|
|
96
|
+
/** The MCP server registration of `client`: whether there is one (reported), and the endpoints it names. */
|
|
97
|
+
function doctorMcp(client, endpoints, defaultEndpoint, report, mcp) {
|
|
98
|
+
const mcpFile = mcpConfigFile(client);
|
|
99
|
+
if (!mcp) {
|
|
100
|
+
report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
endpoints.add(mcp.endpoint === undefined ? defaultEndpoint : mcp.endpoint || LOCAL_ENDPOINT);
|
|
104
|
+
report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
|
|
105
|
+
const [npx, ...rest] = mcp.args;
|
|
106
|
+
if (npx && rest.join(" ") === `-y ${MCP_PACKAGE} mcp`)
|
|
107
|
+
report(`MCP npx ${npx}`, checkEntry(npx));
|
|
108
|
+
else
|
|
109
|
+
report(`MCP command in ${mcpFile}`, bad(`not in the form "<node>" "<npx>" -y ${MCP_PACKAGE} mcp`));
|
|
110
|
+
}
|
|
111
|
+
/** pi: the extension file that carries the hooks, and the MCP server. */
|
|
112
|
+
function doctorPi(endpoints, defaultEndpoint) {
|
|
113
|
+
const file = piExtensionFile();
|
|
114
|
+
const mcp = readMcpCommand("pi", mcpConfigFile("pi"));
|
|
115
|
+
const text = readPiExtensionFile(file);
|
|
116
|
+
if (text === undefined && !mcp)
|
|
117
|
+
return undefined;
|
|
118
|
+
const lines = [];
|
|
119
|
+
let failed = false;
|
|
120
|
+
let versioned = false;
|
|
121
|
+
const report = (label, check) => {
|
|
122
|
+
lines.push(` ${check.ok ? "ok " : "FAIL"} ${label}: ${check.text}`);
|
|
123
|
+
if (!check.ok)
|
|
124
|
+
failed = true;
|
|
125
|
+
if (check.text.includes(VERSIONED_NOTE))
|
|
126
|
+
versioned = true;
|
|
127
|
+
};
|
|
128
|
+
lines.push("pi");
|
|
129
|
+
if (text === undefined) {
|
|
130
|
+
report(`extension in ${file}`, bad("not installed"));
|
|
131
|
+
}
|
|
132
|
+
else if (!text.startsWith(PI_EXTENSION_MARK)) {
|
|
133
|
+
report(`extension in ${file}`, bad("not written by n2n-nexus"));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const command = readPiExtension(text);
|
|
137
|
+
if (!command) {
|
|
138
|
+
report(`extension in ${file}`, bad("its Node path and entry script cannot be read"));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (command.endpoint)
|
|
142
|
+
endpoints.add(command.endpoint);
|
|
143
|
+
else
|
|
144
|
+
endpoints.add(defaultEndpoint);
|
|
145
|
+
report(`extension Node ${command.node}`, checkNode(command.node));
|
|
146
|
+
report(`extension entry ${command.entry}`, checkEntry(command.entry));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
doctorMcp("pi", endpoints, defaultEndpoint, report, mcp);
|
|
150
|
+
if (failed) {
|
|
151
|
+
lines.push(` fix: run "n2n-nexus install pi" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
|
|
152
|
+
}
|
|
153
|
+
else if (versioned) {
|
|
154
|
+
lines.push(` note: after upgrading Node, run "n2n-nexus install pi" again, or install with --node <path> pointing at a stable entry such as /usr/local/bin/node.`);
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
157
|
+
return !failed;
|
|
158
|
+
}
|
|
95
159
|
function doctorClient(client, endpoints, defaultEndpoint) {
|
|
160
|
+
if (client === "pi")
|
|
161
|
+
return doctorPi(endpoints, defaultEndpoint);
|
|
96
162
|
const hooksFile = configFile(client);
|
|
97
163
|
const mcpFile = mcpConfigFile(client);
|
|
98
164
|
const lines = [];
|
|
@@ -126,18 +192,7 @@ function doctorClient(client, endpoints, defaultEndpoint) {
|
|
|
126
192
|
}
|
|
127
193
|
if (client === "claude" && commands.size > 0)
|
|
128
194
|
report(`background wait hook in ${hooksFile}`, checkClaudeWait(config));
|
|
129
|
-
|
|
130
|
-
report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
endpoints.add(mcp.endpoint === undefined ? defaultEndpoint : mcp.endpoint || LOCAL_ENDPOINT);
|
|
134
|
-
report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
|
|
135
|
-
const [npx, ...rest] = mcp.args;
|
|
136
|
-
if (npx && rest.join(" ") === `-y ${MCP_PACKAGE} mcp`)
|
|
137
|
-
report(`MCP npx ${npx}`, checkEntry(npx));
|
|
138
|
-
else
|
|
139
|
-
report(`MCP command in ${mcpFile}`, bad(`not in the form "<node>" "<npx>" -y ${MCP_PACKAGE} mcp`));
|
|
140
|
-
}
|
|
195
|
+
doctorMcp(client, endpoints, defaultEndpoint, report, mcp);
|
|
141
196
|
if (failed) {
|
|
142
197
|
lines.push(` fix: run "n2n-nexus install ${client}" again (add --node <path> to use a stable Node path such as /usr/local/bin/node).`);
|
|
143
198
|
}
|
|
@@ -178,11 +233,11 @@ async function doctorCodexTrust() {
|
|
|
178
233
|
+ ` fix: run "n2n-nexus install codex" again, or ${MANUAL_TRUST}.\n`);
|
|
179
234
|
return false;
|
|
180
235
|
}
|
|
181
|
-
/** Parse `[claude|codex]`, run the checks and return the exit code. */
|
|
236
|
+
/** Parse `[claude|codex|pi]`, run the checks and return the exit code. */
|
|
182
237
|
export async function runDoctor(argv) {
|
|
183
238
|
const client = argv[0];
|
|
184
239
|
if (client !== undefined && !isHookClient(client)) {
|
|
185
|
-
process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
|
|
240
|
+
process.stderr.write("Usage: n2n-nexus doctor [claude|codex|pi]\n");
|
|
186
241
|
return 1;
|
|
187
242
|
}
|
|
188
243
|
try {
|
package/build/cli/hook.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n2n-nexus hook <claude|codex> [--endpoint <url>] [--wait]`: forward one client
|
|
2
|
+
* `n2n-nexus hook <claude|codex|pi> [--endpoint <url>] [--wait]`: forward one client
|
|
3
3
|
* hook event to the daemon and print its output, or with `--wait` run the
|
|
4
4
|
* background wait that wakes an idle conversation. Runs on every tool call, so
|
|
5
5
|
* it loads only node built-ins (no MCP SDK or zod; node:sqlite only to read a
|
|
@@ -35,7 +35,7 @@ function argValue(argv, name) {
|
|
|
35
35
|
function clientAndEndpoint(argv) {
|
|
36
36
|
const client = argv[0];
|
|
37
37
|
if (!isHookClient(client))
|
|
38
|
-
throw new Error(`unknown client "${client ?? ""}" (expected claude or
|
|
38
|
+
throw new Error(`unknown client "${client ?? ""}" (expected claude, codex or pi)`);
|
|
39
39
|
return { client, configured: configuredEndpoint(argValue(argv, "--endpoint")) };
|
|
40
40
|
}
|
|
41
41
|
/** Returns the text to print on stdout ("" for nothing); throws on any failure. */
|
|
@@ -57,6 +57,12 @@ async function forward(argv) {
|
|
|
57
57
|
}
|
|
58
58
|
// Every event says where the conversation runs; the host refuses one without it.
|
|
59
59
|
input.machine = machineInfo();
|
|
60
|
+
// A pi process names itself (--client-pid, its extension passes it): tool calls from it find the session.
|
|
61
|
+
if (client === "pi") {
|
|
62
|
+
const pid = parsePid(argValue(argv, "--client-pid"));
|
|
63
|
+
if (pid !== undefined)
|
|
64
|
+
input.client_pid = pid;
|
|
65
|
+
}
|
|
60
66
|
// SessionEnd must finish within Codex's 3 s hook limit (discovery included): a short budget and a single attempt.
|
|
61
67
|
const sessionEnd = stdin.hook_event_name === "SessionEnd";
|
|
62
68
|
const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : sessionEnd ? SESSION_END_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
|
package/build/cli/install.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `n2n-nexus install <claude|codex> [--endpoint <url>] [--node <path>]` and
|
|
3
|
-
* `n2n-nexus uninstall <claude|codex>`: register the hooks and the MCP server
|
|
4
|
-
* with Claude Code or
|
|
2
|
+
* `n2n-nexus install <claude|codex|pi> [--endpoint <url>] [--node <path>]` and
|
|
3
|
+
* `n2n-nexus uninstall <claude|codex|pi>`: register the hooks and the MCP server
|
|
4
|
+
* with Claude Code, Codex or pi. Our hook entries are recognised by a command that
|
|
5
5
|
* contains both `n2n-nexus` and ` hook <client>`; everything else in the
|
|
6
|
-
* configuration file is kept as it is.
|
|
6
|
+
* configuration file is kept as it is. pi has no hook configuration: its hooks
|
|
7
|
+
* are an extension file (pi.ts), which install writes and uninstall removes.
|
|
7
8
|
*
|
|
8
9
|
* For Claude Code, install also writes a marked block into the user's
|
|
9
10
|
* CLAUDE.md that makes direct messages count as the user's own requests
|
|
@@ -28,9 +29,11 @@ import { claudeConfigDir, codexHome } from "./client-dirs.js";
|
|
|
28
29
|
import { entryScript } from "./entry.js";
|
|
29
30
|
import { hookCommand, isOurs, windowsHookCommand } from "./hook-command.js";
|
|
30
31
|
import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
|
|
32
|
+
import { PI_EXTENSION_MARK, piExtensionFile, readPiExtensionFile, renderPiExtension, writePiExtension } from "./pi.js";
|
|
31
33
|
const EVENTS = {
|
|
32
34
|
claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
|
|
33
|
-
codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
|
|
35
|
+
codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"],
|
|
36
|
+
pi: []
|
|
34
37
|
};
|
|
35
38
|
class InstallError extends Error {
|
|
36
39
|
}
|
|
@@ -155,6 +158,8 @@ function checkNodePath(given) {
|
|
|
155
158
|
return node;
|
|
156
159
|
}
|
|
157
160
|
export function configFile(client) {
|
|
161
|
+
if (client === "pi")
|
|
162
|
+
return piExtensionFile();
|
|
158
163
|
return client === "claude" ? path.join(claudeConfigDir(), "settings.json") : path.join(codexHome(), "hooks.json");
|
|
159
164
|
}
|
|
160
165
|
export function readConfig(file) {
|
|
@@ -323,6 +328,30 @@ function mcpRemoveArgs(client) {
|
|
|
323
328
|
? ["mcp", "remove", "--scope", "user", MCP_SERVER_NAME]
|
|
324
329
|
: ["mcp", "remove", MCP_SERVER_NAME];
|
|
325
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Write (install) or remove (uninstall) the pi extension that carries the hooks. An extension file that is not
|
|
333
|
+
* ours is never overwritten or removed. Returns whether an extension of ours was there before.
|
|
334
|
+
*/
|
|
335
|
+
function piExtensionStep(action, command) {
|
|
336
|
+
const file = piExtensionFile();
|
|
337
|
+
const text = readPiExtensionFile(file);
|
|
338
|
+
const ours = text !== undefined && text.startsWith(PI_EXTENSION_MARK);
|
|
339
|
+
if (text !== undefined && !ours) {
|
|
340
|
+
if (action === "install")
|
|
341
|
+
throw new InstallError(`${file} exists and is not written by n2n-nexus; move it aside and run again.`);
|
|
342
|
+
process.stdout.write(`Left ${file} alone: it is not written by n2n-nexus.\n`);
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (action === "install") {
|
|
346
|
+
writePiExtension(file, renderPiExtension(command));
|
|
347
|
+
process.stdout.write(`Wrote the pi extension to ${file}\n`);
|
|
348
|
+
}
|
|
349
|
+
else if (ours) {
|
|
350
|
+
fs.rmSync(file, { force: true });
|
|
351
|
+
process.stdout.write(`Removed the pi extension from ${file}\n`);
|
|
352
|
+
}
|
|
353
|
+
return ours;
|
|
354
|
+
}
|
|
326
355
|
/**
|
|
327
356
|
* `<client> mcp add` for `"<node>" "<npx>" -y n2n-nexus@latest mcp`. PATH starts with `bin` (see npxFor): npx starts
|
|
328
357
|
* the package's bin, `#!/usr/bin/env node` (Unix) or a .cmd shim running `node` (Windows), which looks Node up on
|
|
@@ -377,7 +406,7 @@ function removeMcpServer(client, action, hooksRemoved) {
|
|
|
377
406
|
return;
|
|
378
407
|
if (action === "uninstall" && error.code === "ENOENT") {
|
|
379
408
|
const file = mcpConfigFile(client);
|
|
380
|
-
const what = client === "
|
|
409
|
+
const what = client === "codex" ? `the [mcp_servers.${MCP_SERVER_NAME}] table` : `the "${MCP_SERVER_NAME}" entry under "mcpServers"`;
|
|
381
410
|
process.stdout.write(`Skipped "${client} ${removeArgs.join(" ")}": the "${client}" command could not be run (not on PATH, or not runnable without a shell). `
|
|
382
411
|
+ `The MCP server is still registered in ${file}; run that command once ${client} can be run, or delete ${what} from ${file}.\n`);
|
|
383
412
|
return;
|
|
@@ -403,22 +432,28 @@ export function runInstall(options) {
|
|
|
403
432
|
if (action === "install") {
|
|
404
433
|
const probe = spawnClientSync(client, ["--version"], { stdio: "ignore" });
|
|
405
434
|
if (probe.error || probe.status !== 0) {
|
|
406
|
-
throw new InstallError(`The "${client}" command was not found on PATH; install ${client
|
|
435
|
+
throw new InstallError(`The "${client}" command was not found on PATH; install ${CLIENT_NAMES[client]} first.`);
|
|
407
436
|
}
|
|
408
437
|
}
|
|
409
438
|
const node = action === "install" ? (options.node ? checkNodePath(options.node) : stableNodePath()) : undefined;
|
|
410
439
|
const npm = node ? npxFor(node) : undefined;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (action === "install") {
|
|
415
|
-
const command = hookCommand(node, entry, client, endpoint);
|
|
416
|
-
const commandWindows = client === "codex" ? windowsHookCommand(node, entry, client, endpoint) : undefined;
|
|
417
|
-
addOurEntries(config, client, command, commandWindows);
|
|
440
|
+
let removed;
|
|
441
|
+
if (client === "pi") {
|
|
442
|
+
removed = piExtensionStep(action, node ? { node, entry, endpoint } : undefined);
|
|
418
443
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
444
|
+
else {
|
|
445
|
+
const file = configFile(client);
|
|
446
|
+
const config = readConfig(file);
|
|
447
|
+
removed = removeOurEntries(config, client, file);
|
|
448
|
+
if (action === "install") {
|
|
449
|
+
const command = hookCommand(node, entry, client, endpoint);
|
|
450
|
+
const commandWindows = client === "codex" ? windowsHookCommand(node, entry, client, endpoint) : undefined;
|
|
451
|
+
addOurEntries(config, client, command, commandWindows);
|
|
452
|
+
}
|
|
453
|
+
if (action === "install" || removed) {
|
|
454
|
+
writeConfig(file, config);
|
|
455
|
+
process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
|
|
456
|
+
}
|
|
422
457
|
}
|
|
423
458
|
if (client === "claude" && writeInstructions(action === "install")) {
|
|
424
459
|
process.stdout.write(`${action === "install" ? "Wrote the n2n-nexus instructions to" : "Removed the n2n-nexus instructions from"} ${instructionsFile()}\n`);
|
|
@@ -440,11 +475,13 @@ export function runInstall(options) {
|
|
|
440
475
|
return 1;
|
|
441
476
|
}
|
|
442
477
|
}
|
|
443
|
-
/**
|
|
478
|
+
/** The product names the installer's messages use. */
|
|
479
|
+
const CLIENT_NAMES = { claude: "Claude Code", codex: "Codex", pi: "pi" };
|
|
480
|
+
/** Parse `<claude|codex|pi> [--endpoint <url>] [--node <path>]` and run. */
|
|
444
481
|
export async function runInstallCommand(action, argv) {
|
|
445
482
|
const client = argv[0];
|
|
446
483
|
if (!isHookClient(client)) {
|
|
447
|
-
process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
|
|
484
|
+
process.stderr.write(`Usage: n2n-nexus ${action} <claude|codex|pi>${action === "install" ? " [--endpoint <url>] [--node <path>]" : ""}\n`);
|
|
448
485
|
return 1;
|
|
449
486
|
}
|
|
450
487
|
const option = (name, what) => {
|
package/build/cli/mcp-config.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where Claude Code and
|
|
3
|
-
* read the n2n-nexus entry: `~/.claude.json` (user scope)
|
|
4
|
-
*
|
|
2
|
+
* Where Claude Code, Codex and pi keep their MCP server registrations, and how to
|
|
3
|
+
* read the n2n-nexus entry: `~/.claude.json` (user scope), `$CODEX_HOME/config.toml`
|
|
4
|
+
* and pi's `mcp.json`. `claude mcp get` is not used for this because it
|
|
5
5
|
* looks in every scope, not only the user scope that install writes to, and
|
|
6
6
|
* it starts the server for a health check.
|
|
7
7
|
*/
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { MCP_SERVER_NAME } from "../contract.js";
|
|
11
|
-
import { claudeJsonDir, codexHome } from "./client-dirs.js";
|
|
11
|
+
import { claudeJsonDir, codexHome, piAgentDir } from "./client-dirs.js";
|
|
12
12
|
/** Where the client keeps the MCP server command. */
|
|
13
13
|
export function mcpConfigFile(client) {
|
|
14
|
-
|
|
14
|
+
if (client === "claude")
|
|
15
|
+
return path.join(claudeJsonDir(), ".claude.json");
|
|
16
|
+
if (client === "pi")
|
|
17
|
+
return path.join(piAgentDir(), "mcp.json");
|
|
18
|
+
return path.join(codexHome(), "config.toml");
|
|
15
19
|
}
|
|
16
20
|
/** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
|
|
17
21
|
export function readMcpCommand(client, file) {
|
|
@@ -24,7 +28,7 @@ export function readMcpCommand(client, file) {
|
|
|
24
28
|
return undefined;
|
|
25
29
|
throw error;
|
|
26
30
|
}
|
|
27
|
-
if (client === "claude") {
|
|
31
|
+
if (client === "claude" || client === "pi") {
|
|
28
32
|
let parsed;
|
|
29
33
|
try {
|
|
30
34
|
parsed = JSON.parse(text);
|