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 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 Codex conversations message each other directly: a message arrives whole through the client's hooks, wakes a waiting conversation, and is answered with `reply`; each session shows the machine and environment it runs on.
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 Codex conversation has its own identity**: client hooks register the conversation with the host and deliver new direct messages and meeting messages to it; each tool call names its conversation through the request metadata the client attaches, never through model-supplied arguments.
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 Codex (`npm i -g n2n-nexus@latest`, then `n2n-nexus install claude` or `n2n-nexus install codex`): [step 3](docs/INSTALL.md#3-install-the-client-for-claude-code-and-codex).
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 `~/.codex`. Conversation IDs and Claude tool-use bindings 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.
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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Where Claude Code and Codex keep their configuration. Node built-ins only:
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.
@@ -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
- if (!mcp) {
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 codex)`);
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;
@@ -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 Codex. Our hook entries are recognised by a command that
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 === "claude" ? `the "${MCP_SERVER_NAME}" entry under "mcpServers"` : `the [mcp_servers.${MCP_SERVER_NAME}] table`;
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 === "claude" ? "Claude Code" : "Codex"} first.`);
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
- const file = configFile(client);
412
- const config = readConfig(file);
413
- const removed = removeOurEntries(config, client, file);
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
- if (action === "install" || removed) {
420
- writeConfig(file, config);
421
- process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
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
- /** Parse `<claude|codex> [--endpoint <url>] [--node <path>]` and run. */
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) => {
@@ -1,17 +1,21 @@
1
1
  /**
2
- * Where Claude Code and Codex keep their MCP server registrations, and how to
3
- * read the n2n-nexus entry: `~/.claude.json` (user scope) and
4
- * `$CODEX_HOME/config.toml`. `claude mcp get` is not used for this because it
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
- return client === "claude" ? path.join(claudeJsonDir(), ".claude.json") : path.join(codexHome(), "config.toml");
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);