n2n-nexus 0.12.5 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@
4
4
 
5
5
  All notable changes to this project will be documented in this file.
6
6
 
7
+ ## [0.13.0] - 2026-11-15
8
+
9
+ 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, and Claude Code and Codex are not affected). The database is upgraded in place: keep `nexus.db`. An older host refuses the upgraded database.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+
15
+ ## [0.12.6] - 2026-11-10
16
+
17
+ 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.
18
+
19
+ ### Changed
20
+
21
+ - Every direct message wakes its recipient: `send` and `reply` no longer take `wake`, so a sender cannot keep a message from waking an idle conversation or holding a working one's turn at its end. `expectsReply: false` only says that no reply is needed. Plain acknowledgements are still not to be sent. Broadcasts are unchanged.
22
+ - The background wait that wakes an idle conversation lasts up to a day after its last turn instead of an hour (`waitHookTimeoutSec` 86400): the timeout of the Claude Code `--wait` hook and the deadline of the Codex wait.
23
+
24
+ ### Fixed
25
+
26
+ - A background wait whose client was killed or crashed (no `SessionEnd`) no longer keeps the conversation `listening`, nor takes a wake for it: before each request (at most 590 s apart) the wait checks that Claude Code (`CLAUDE_PID`) or the Codex process that ran the Stop hook still runs and ends when it does not, and a wake that arrives after that is reported as not delivered, so the host announces its messages again instead of marking them read. Before, a Claude Code conversation killed with its wait still running lost the message that woke it.
27
+ - The Codex wait stops trying `codex queue` after ten failures in a row (about 8.5 minutes) instead of until its deadline: Codex does not have the conversation loaded, and with a day's deadline a closed terminal (its app-server still running) would otherwise retry every 5 minutes all day. The messages stay unread for the user's next prompt.
28
+ - `n2n-nexus doctor claude` reports a background wait hook whose timeout is not this client's (left by an older install) or that is missing.
29
+ - A tool call that names neither a conversation nor an instance (no `instanceId` in `POST /api/tools/call`, no hooks) is refused by every tool that acts as someone: `send_message`, `start_meeting`, `end_meeting`, `archive_meeting`, `reopen_meeting`, `join_meeting`, `leave_meeting` and `stance`, as `read_messages` already was. Before, all such callers acted as one shared identity `unknown`, with one membership and one read cursor. The MCP adapter always sends `instanceId`.
30
+ - Meeting messages that a tool call's notification announced (its context may be a sub-agent's) are no longer lost to the conversation: the member is told at its next prompt how many earlier messages of others are still unread, and an active member's turn continues once for them at its end, as for an earlier direct message still unread.
31
+ - LAN discovery amplifies nothing: the query is padded to 512 bytes (`discoveryQueryBytes`), and the host answers only that datagram and never with more bytes than it got. Before, an 18-byte query got a reply of about 70 bytes sent to an unverified source address. An older client's query gets no answer, hence contract version 5.
32
+
7
33
  ## [0.12.5] - 2026-11-04
8
34
 
9
35
  Upgrade the host; the client needs no update for these tools (contract version 4). 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).
@@ -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");
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The client process a background wait would wake, so that the wait ends when
3
+ * it is gone instead of keeping the session listening: Claude Code names its
4
+ * own process in CLAUDE_PID; for Codex the Stop hook looks it up among its
5
+ * ancestors and hands it to the detached wait (`--client-pid`).
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ /** Shells a client may run a hook command through (Codex on Windows: PowerShell for commandWindows). */
11
+ export const SHELL_NAMES = ["sh", "bash", "dash", "zsh", "fish", "powershell", "pwsh", "cmd"];
12
+ /** Whether process `pid` runs (EPERM: it runs, as another user). */
13
+ export function processAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return error.code === "EPERM";
20
+ }
21
+ }
22
+ /** A positive integer process id, else undefined. */
23
+ export function parsePid(value) {
24
+ const pid = Number(value);
25
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
26
+ }
27
+ /** The name (lower case) and parent of process `pid` on Linux or macOS; throws when it cannot be read. */
28
+ function describe(pid) {
29
+ let name;
30
+ let ppid;
31
+ if (process.platform === "linux") {
32
+ // "<pid> (<comm>) <state> <ppid> ...": comm may hold spaces and parentheses.
33
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
34
+ const close = stat.lastIndexOf(")");
35
+ name = stat.slice(stat.indexOf("(") + 1, close);
36
+ ppid = Number(stat.slice(close + 2).split(" ")[1]);
37
+ }
38
+ else {
39
+ const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 }).trim();
40
+ const m = /^(\d+)\s+(.*)$/.exec(out);
41
+ if (!m)
42
+ throw new Error(`no process ${pid}`);
43
+ ppid = Number(m[1]);
44
+ name = path.basename(m[2]);
45
+ }
46
+ if (!Number.isSafeInteger(ppid) || ppid <= 0 || !name)
47
+ throw new Error(`no process ${pid}`);
48
+ return { name: name.toLowerCase(), ppid };
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
+ }
75
+ /**
76
+ * The process that ran this hook, past the shells it went through: the
77
+ * nearest ancestor that is not a shell; undefined when it cannot be told.
78
+ * Linux and macOS only: on Windows the PowerShell that starts the detached wait
79
+ * looks it up itself (hook.ts startDetached), saving a PowerShell of its own.
80
+ */
81
+ export function hookClientPid() {
82
+ if (process.platform === "win32")
83
+ return undefined;
84
+ try {
85
+ let pid = process.ppid;
86
+ for (let hops = 0; hops < 4; hops++) {
87
+ const { name, ppid } = describe(pid);
88
+ if (!SHELL_NAMES.includes(name))
89
+ return pid;
90
+ pid = ppid;
91
+ }
92
+ }
93
+ catch {
94
+ // Not readable: the wait runs without the check.
95
+ }
96
+ return undefined;
97
+ }
@@ -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
@@ -10,13 +10,14 @@
10
10
  * check failed.
11
11
  */
12
12
  import fs from "node:fs";
13
- import { CONTRACT_VERSION, HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient, noDaemonHint } from "../contract.js";
13
+ import { CONTRACT_VERSION, HOOK_CLIENTS, MCP_SERVER_NAME, WAIT_HOOK_TIMEOUT_SEC, isHookClient, noDaemonHint } from "../contract.js";
14
14
  import { NexusClient, NexusToolError } from "../client/nexus-client.js";
15
15
  import { LOCAL_ENDPOINT, configuredEndpoint, resolveEndpoint } from "../client/discovery.js";
16
16
  import { pkg } from "../config/index.js";
17
- import { hookEndpoint, ourCommands, parseHookCommand } from "./hook-command.js";
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";
@@ -53,6 +54,28 @@ export async function checkHost(endpoint) {
53
54
  }
54
55
  return ok(`n2n-nexus-host ${version}, contract version ${CONTRACT_VERSION}`);
55
56
  }
57
+ /**
58
+ * Claude's background wait hook (the Stop hook running `hook claude --wait`)
59
+ * must be an asyncRewake hook with this client's timeout: the wait ends just
60
+ * before WAIT_HOOK_TIMEOUT_SEC, and a shorter timeout written by an older
61
+ * install kills it earlier, possibly while it hands over a wake.
62
+ */
63
+ function checkClaudeWait(config) {
64
+ const groups = config.hooks?.Stop;
65
+ for (const group of Array.isArray(groups) ? groups : []) {
66
+ for (const entry of Array.isArray(group?.hooks) ? group.hooks : []) {
67
+ if (!isOurs(entry, "claude") || !entry.command.endsWith(" --wait"))
68
+ continue;
69
+ const { asyncRewake, timeout } = entry;
70
+ if (asyncRewake !== true)
71
+ return bad("not an asyncRewake hook");
72
+ if (timeout !== WAIT_HOOK_TIMEOUT_SEC)
73
+ return bad(`timeout ${String(timeout)} s, this client needs ${WAIT_HOOK_TIMEOUT_SEC} s`);
74
+ return ok(`timeout ${WAIT_HOOK_TIMEOUT_SEC} s`);
75
+ }
76
+ }
77
+ return bad("missing");
78
+ }
56
79
  /** Check the host at each endpoint; prints the report and returns whether all passed. */
57
80
  async function doctorHosts(endpoints) {
58
81
  let passed = true;
@@ -70,7 +93,72 @@ async function doctorHosts(endpoints) {
70
93
  * Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed.
71
94
  * Adds the endpoints its hook and MCP commands use to `endpoints`, falling back to defaultEndpoint.
72
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
+ }
73
159
  function doctorClient(client, endpoints, defaultEndpoint) {
160
+ if (client === "pi")
161
+ return doctorPi(endpoints, defaultEndpoint);
74
162
  const hooksFile = configFile(client);
75
163
  const mcpFile = mcpConfigFile(client);
76
164
  const lines = [];
@@ -83,7 +171,8 @@ function doctorClient(client, endpoints, defaultEndpoint) {
83
171
  if (check.text.includes(VERSIONED_NOTE))
84
172
  versioned = true;
85
173
  };
86
- const commands = ourCommands(readConfig(hooksFile), client);
174
+ const config = readConfig(hooksFile);
175
+ const commands = ourCommands(config, client);
87
176
  const mcp = readMcpCommand(client, mcpFile);
88
177
  if (commands.size === 0 && !mcp)
89
178
  return undefined;
@@ -101,18 +190,9 @@ function doctorClient(client, endpoints, defaultEndpoint) {
101
190
  report(`hook Node ${parsed.node}`, checkNode(parsed.node));
102
191
  report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
103
192
  }
104
- if (!mcp) {
105
- report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
106
- }
107
- else {
108
- endpoints.add(mcp.endpoint === undefined ? defaultEndpoint : mcp.endpoint || LOCAL_ENDPOINT);
109
- report(`MCP Node ${mcp.command}`, checkNode(mcp.command));
110
- const [npx, ...rest] = mcp.args;
111
- if (npx && rest.join(" ") === `-y ${MCP_PACKAGE} mcp`)
112
- report(`MCP npx ${npx}`, checkEntry(npx));
113
- else
114
- report(`MCP command in ${mcpFile}`, bad(`not in the form "<node>" "<npx>" -y ${MCP_PACKAGE} mcp`));
115
- }
193
+ if (client === "claude" && commands.size > 0)
194
+ report(`background wait hook in ${hooksFile}`, checkClaudeWait(config));
195
+ doctorMcp(client, endpoints, defaultEndpoint, report, mcp);
116
196
  if (failed) {
117
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).`);
118
198
  }
@@ -153,11 +233,11 @@ async function doctorCodexTrust() {
153
233
  + ` fix: run "n2n-nexus install codex" again, or ${MANUAL_TRUST}.\n`);
154
234
  return false;
155
235
  }
156
- /** Parse `[claude|codex]`, run the checks and return the exit code. */
236
+ /** Parse `[claude|codex|pi]`, run the checks and return the exit code. */
157
237
  export async function runDoctor(argv) {
158
238
  const client = argv[0];
159
239
  if (client !== undefined && !isHookClient(client)) {
160
- process.stderr.write("Usage: n2n-nexus doctor [claude|codex]\n");
240
+ process.stderr.write("Usage: n2n-nexus doctor [claude|codex|pi]\n");
161
241
  return 1;
162
242
  }
163
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
@@ -11,6 +11,7 @@ import path from "node:path";
11
11
  import { BACKGROUND_WAIT_EVENT, WAKE_RESULT_EVENT, HOOK_INPUT_FIELDS, HOOK_REQUEST_TIMEOUT_MS, HOOK_STOP_REQUEST_TIMEOUT_MS, SESSION_END_REQUEST_TIMEOUT_MS, WAIT_HOOK_TIMEOUT_SEC, WAKE_RETRY_FIRST_DELAY_MS, WAKE_RETRY_MAX_DELAY_MS, noDaemonHint, isHookClient } from "../contract.js";
12
12
  import { NexusClient, NexusTransportError, REQUEST_TIMEOUT_CODE } from "../client/nexus-client.js";
13
13
  import { DISCOVER_ENDPOINT, configuredEndpoint, resolveEndpoint } from "../client/discovery.js";
14
+ import { SHELL_NAMES, hookClientPid, parsePid, processAlive } from "./client-process.js";
14
15
  import { machineInfo } from "./machine.js";
15
16
  import { readTitle } from "./title.js";
16
17
  /** Errors after which a discovered daemon is looked up again: it was never reached, so resending is safe. */
@@ -34,7 +35,7 @@ function argValue(argv, name) {
34
35
  function clientAndEndpoint(argv) {
35
36
  const client = argv[0];
36
37
  if (!isHookClient(client))
37
- throw new Error(`unknown client "${client ?? ""}" (expected claude or codex)`);
38
+ throw new Error(`unknown client "${client ?? ""}" (expected claude, codex or pi)`);
38
39
  return { client, configured: configuredEndpoint(argValue(argv, "--endpoint")) };
39
40
  }
40
41
  /** Returns the text to print on stdout ("" for nothing); throws on any failure. */
@@ -56,6 +57,12 @@ async function forward(argv) {
56
57
  }
57
58
  // Every event says where the conversation runs; the host refuses one without it.
58
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
+ }
59
66
  // SessionEnd must finish within Codex's 3 s hook limit (discovery included): a short budget and a single attempt.
60
67
  const sessionEnd = stdin.hook_event_name === "SessionEnd";
61
68
  const timeoutMs = stdin.hook_event_name === "Stop" ? HOOK_STOP_REQUEST_TIMEOUT_MS : sessionEnd ? SESSION_END_REQUEST_TIMEOUT_MS : HOOK_REQUEST_TIMEOUT_MS;
@@ -101,9 +108,10 @@ const WAIT_DEADLINE_MS = (WAIT_HOOK_TIMEOUT_SEC - 10) * 1000;
101
108
  const WAIT_RETRY_CODES = new Set([...UNREACHABLE_CODES, "ECONNRESET", "EPIPE", "ENOTFOUND", "EAI_AGAIN", REQUEST_TIMEOUT_CODE]);
102
109
  /**
103
110
  * Ask the daemon until something arrives for the session (the wake), the
104
- * session becomes active again or the deadline passes (null).
111
+ * session becomes active again, the deadline passes or, checked before each
112
+ * request, `stop` says the client is gone (null).
105
113
  */
106
- export async function waitForWake(client, sessionId, configured, deadlineMs = WAIT_DEADLINE_MS) {
114
+ export async function waitForWake(client, sessionId, configured, deadlineMs = WAIT_DEADLINE_MS, stop = () => false) {
107
115
  const deadline = Date.now() + deadlineMs;
108
116
  let endpoint = await resolveEndpoint(configured);
109
117
  let delay = WAKE_RETRY_FIRST_DELAY_MS;
@@ -112,7 +120,7 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
112
120
  if (fresh)
113
121
  endpoint = await resolveEndpoint(configured, { fresh: true });
114
122
  const remaining = deadline - Date.now();
115
- if (remaining <= 0)
123
+ if (remaining <= 0 || stop())
116
124
  return null;
117
125
  let response;
118
126
  try {
@@ -138,6 +146,18 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
138
146
  }
139
147
  return null;
140
148
  }
149
+ /**
150
+ * The delay before the Codex wait tries again after the failed `codex queue`
151
+ * it waited `delay` before (the first failure waits WAKE_RETRY_FIRST_DELAY_MS),
152
+ * doubled each time, or null once it would reach WAKE_RETRY_MAX_DELAY_MS
153
+ * (about 8.5 minutes of failures): Codex does not have the conversation loaded
154
+ * (its terminal was closed, the app restarted), so the wait ends, and the
155
+ * messages, undone by the failure report, wait for the user's next prompt there.
156
+ */
157
+ export function nextQueueRetry(delay) {
158
+ const next = delay * 2;
159
+ return next >= WAKE_RETRY_MAX_DELAY_MS ? null : next;
160
+ }
141
161
  /**
142
162
  * `--wait`, started by a Stop hook when a turn ends. Claude runs it as an
143
163
  * asyncRewake hook: exit code 2 with the notification on stderr wakes the
@@ -147,7 +167,15 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
147
167
  * and reports to the host whether it did (WAKE_RESULT_EVENT): after a failed
148
168
  * queue the host undoes the wake and the copy waits again after a delay that
149
169
  * doubles on each failure, so the same messages wake the session on a later
150
- * try without retrying a broken `codex` in a loop.
170
+ * try without retrying a broken `codex` in a loop; once the delay reaches its
171
+ * maximum the copy ends, and the messages wait for the user's next prompt.
172
+ *
173
+ * The wait ends when the client it would wake is gone (Claude Code's
174
+ * CLAUDE_PID, or the Codex process the Stop hook passes as `--client-pid`),
175
+ * checked before each request, so a client that was killed is not kept
176
+ * listening; a wake that arrives after it is gone is reported as not delivered.
177
+ * A request is never cut short for it: the host may already have handed over a
178
+ * wake on it, which only a report can undo.
151
179
  */
152
180
  async function runWait(argv) {
153
181
  const { client, configured } = clientAndEndpoint(argv);
@@ -163,18 +191,27 @@ async function runWait(argv) {
163
191
  if (client === "codex") {
164
192
  const { entryScript } = await import("./entry.js");
165
193
  const args = [entryScript(), "hook", "codex", "--wait", "--session", sessionId, ...(argValue(argv, "--endpoint") ? ["--endpoint", configured] : [])];
166
- await startDetached(process.execPath, args);
194
+ await startDetached(process.execPath, args, hookClientPid);
167
195
  return 0;
168
196
  }
169
197
  }
170
- // Load what a wake needs before waiting: the wait lasts up to an hour, and an
198
+ const clientPid = parsePid(client === "claude" ? process.env.CLAUDE_PID : argValue(argv, "--client-pid"));
199
+ const clientGone = () => clientPid !== undefined && !processAlive(clientPid);
200
+ // Load what a wake needs before waiting: the wait lasts up to a day, and an
171
201
  // upgrade or a cleared npx cache may remove the package's files meanwhile.
172
202
  const { spawnClientSync } = client === "codex" ? await import("./client-command.js") : { spawnClientSync: undefined };
203
+ const report = (wake, ok) => new NexusClient({ endpoint: wake.endpoint })
204
+ .hook({ client, input: { hook_event_name: WAKE_RESULT_EVENT, session_id: sessionId, dispatch_id: wake.dispatchId, ok } }, HOOK_REQUEST_TIMEOUT_MS)
205
+ .then(() => true, () => false);
173
206
  const deadline = Date.now() + WAIT_DEADLINE_MS;
174
- for (let delay = WAKE_RETRY_FIRST_DELAY_MS;; delay = Math.min(delay * 2, WAKE_RETRY_MAX_DELAY_MS)) {
175
- const wake = await waitForWake(client, sessionId, configured, deadline - Date.now());
207
+ for (let delay = WAKE_RETRY_FIRST_DELAY_MS;; delay = nextQueueRetry(delay)) {
208
+ const wake = await waitForWake(client, sessionId, configured, deadline - Date.now(), clientGone);
176
209
  if (wake === null)
177
210
  return 0;
211
+ if (clientGone()) {
212
+ await report(wake, false);
213
+ return 0;
214
+ }
178
215
  if (!spawnClientSync) {
179
216
  await new Promise((resolve) => process.stderr.write(wake.text + "\n", () => resolve()));
180
217
  return 2;
@@ -182,10 +219,9 @@ async function runWait(argv) {
182
219
  // Never through cmd.exe: the text comes from another session and spans several lines.
183
220
  const queued = spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", wake.text], { stdio: "ignore", windowsHide: true });
184
221
  const ok = !queued.error && queued.status === 0;
185
- const input = { hook_event_name: WAKE_RESULT_EVENT, session_id: sessionId, dispatch_id: wake.dispatchId, ok };
186
- const reported = await new NexusClient({ endpoint: wake.endpoint }).hook({ client, input }, HOOK_REQUEST_TIMEOUT_MS).then(() => true, () => false);
222
+ const reported = await report(wake, ok);
187
223
  // Unknown result (no report): the host keeps the wake, and waiting again would deliver nothing.
188
- if (ok || !reported || Date.now() + delay >= deadline)
224
+ if (ok || !reported || delay === null || Date.now() + delay >= deadline)
189
225
  return 0;
190
226
  await new Promise((resolve) => setTimeout(resolve, delay));
191
227
  }
@@ -196,17 +232,27 @@ async function runWait(argv) {
196
232
  * on: a process Node spawns inherits every inheritable handle of this one, and
197
233
  * when Codex runs the hook through PowerShell (`commandWindows`) those include
198
234
  * the pipes Codex reads the hook's output from. Codex then waits for the pipes
199
- * until the hook's timeout and ends the wait copy with the hook.
235
+ * until the hook's timeout and ends the wait copy with the hook. The copy gets
236
+ * the client process as `--client-pid`: from `clientPid` on Linux and macOS,
237
+ * found by the launching PowerShell itself on Windows.
200
238
  */
201
- async function startDetached(file, args) {
239
+ async function startDetached(file, args, clientPid) {
202
240
  if (process.platform !== "win32") {
203
- spawn(file, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
241
+ const pid = clientPid();
242
+ spawn(file, [...args, ...(pid !== undefined ? ["--client-pid", String(pid)] : [])], { detached: true, stdio: "ignore", windowsHide: true }).unref();
204
243
  return;
205
244
  }
206
245
  const literal = (s) => `'${s.replace(/'/g, "''")}'`;
207
246
  // Start-Process joins -ArgumentList with spaces, so each argument is quoted for the command line itself.
208
247
  const quoted = (s) => `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
209
- const command = `Start-Process -WindowStyle Hidden -FilePath ${literal(file)} -ArgumentList ${args.map((a) => literal(quoted(a))).join(",")}`;
248
+ // The client process (--client-pid) is found here, in the PowerShell that starts the copy anyway, not
249
+ // in one of its own: the nearest ancestor of this hook that is not a shell (Codex runs commandWindows
250
+ // through PowerShell). Not found: the copy runs without it.
251
+ const shells = SHELL_NAMES.map(literal).join(",");
252
+ const command = `$a = @(${args.map((a) => literal(quoted(a))).join(",")}); `
253
+ + `try { $c = ${process.ppid}; for ($i = 0; $i -lt 4; $i++) { $p = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $c); if (-not $p) { break }; `
254
+ + `if (@(${shells}) -notcontains ($p.Name -replace '[.]exe$', '').ToLower()) { $a += ${literal(quoted("--client-pid"))}, ('"' + $c + '"'); break }; $c = $p.ParentProcessId } } catch { }; `
255
+ + `Start-Process -WindowStyle Hidden -FilePath ${literal(file)} -ArgumentList $a`;
210
256
  const powershell = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
211
257
  await new Promise((resolve, reject) => {
212
258
  const launcher = spawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", command], { stdio: "ignore", windowsHide: true });