n2n-nexus 0.12.4 → 0.12.6

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,37 @@
4
4
 
5
5
  All notable changes to this project will be documented in this file.
6
6
 
7
+ ## [0.12.6] - 2026-11-10
8
+
9
+ 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.
10
+
11
+ ### Changed
12
+
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Fixed
17
+
18
+ - 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.
19
+ - 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.
20
+ - `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.
21
+ - 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`.
22
+ - 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.
23
+ - 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.
24
+
25
+ ## [0.12.5] - 2026-11-04
26
+
27
+ 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.
28
+
29
+ ### Added
30
+
31
+ - `broadcast` sends one notice to every conversation that is online at that moment, the sender excepted; `project` limits it to the conversations working in that directory or below it. It is fire and forget: the host picks the recipients and delivers it, the sender gets only a `broadcastId` and learns nothing about who got or read it, and offline conversations or ones that start later never get it. The host stores it once with one delivery per recipient, and every recipient sees it under the same id, in a `--- Broadcast <id> from <sender> ... ---` header. A recipient that is listening for messages is woken with it at once; a working one is not interrupted (its turn is not held) and sees it after its next tool call, and an idle one at its next prompt or tool call. It needs no reply unless the sender sets `expectsReply`, and a `reply` on that id goes to the sender alone, with the `broadcastId` in `replyTo`. An unread broadcast expires after 24 hours like a direct message, without telling anyone. `read` returns `kind` (`direct` or `broadcast`), `scope` and `replyToKind` for each message.
32
+ - `send_message` takes `global: true` to post to the global chat while a meeting is active; before, a post without `meetingId` always went to the active meeting.
33
+
34
+ ### Changed
35
+
36
+ - `host_maintenance` cleanup counts broadcast deliveries too: `messagesExpired` includes the deliveries it marked expired, `messages` the ones it deleted.
37
+
7
38
  ## [0.12.4] - 2026-10-30
8
39
 
9
40
  ### Fixed
package/README.md CHANGED
@@ -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`, `listen`, `set_profile` | Find other Claude Code / Codex conversations, message them, read and answer what arrives, wait for messages, and name yourself. |
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. |
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. |
@@ -0,0 +1,72 @@
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
+ /**
51
+ * The process that ran this hook, past the shells it went through: the
52
+ * nearest ancestor that is not a shell; undefined when it cannot be told.
53
+ * Linux and macOS only: on Windows the PowerShell that starts the detached wait
54
+ * looks it up itself (hook.ts startDetached), saving a PowerShell of its own.
55
+ */
56
+ export function hookClientPid() {
57
+ if (process.platform === "win32")
58
+ return undefined;
59
+ try {
60
+ let pid = process.ppid;
61
+ for (let hops = 0; hops < 4; hops++) {
62
+ const { name, ppid } = describe(pid);
63
+ if (!SHELL_NAMES.includes(name))
64
+ return pid;
65
+ pid = ppid;
66
+ }
67
+ }
68
+ catch {
69
+ // Not readable: the wait runs without the check.
70
+ }
71
+ return undefined;
72
+ }
@@ -10,11 +10,11 @@
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
20
  const ok = (text) => ({ ok: true, text });
@@ -53,6 +53,28 @@ export async function checkHost(endpoint) {
53
53
  }
54
54
  return ok(`n2n-nexus-host ${version}, contract version ${CONTRACT_VERSION}`);
55
55
  }
56
+ /**
57
+ * Claude's background wait hook (the Stop hook running `hook claude --wait`)
58
+ * must be an asyncRewake hook with this client's timeout: the wait ends just
59
+ * before WAIT_HOOK_TIMEOUT_SEC, and a shorter timeout written by an older
60
+ * install kills it earlier, possibly while it hands over a wake.
61
+ */
62
+ function checkClaudeWait(config) {
63
+ const groups = config.hooks?.Stop;
64
+ for (const group of Array.isArray(groups) ? groups : []) {
65
+ for (const entry of Array.isArray(group?.hooks) ? group.hooks : []) {
66
+ if (!isOurs(entry, "claude") || !entry.command.endsWith(" --wait"))
67
+ continue;
68
+ const { asyncRewake, timeout } = entry;
69
+ if (asyncRewake !== true)
70
+ return bad("not an asyncRewake hook");
71
+ if (timeout !== WAIT_HOOK_TIMEOUT_SEC)
72
+ return bad(`timeout ${String(timeout)} s, this client needs ${WAIT_HOOK_TIMEOUT_SEC} s`);
73
+ return ok(`timeout ${WAIT_HOOK_TIMEOUT_SEC} s`);
74
+ }
75
+ }
76
+ return bad("missing");
77
+ }
56
78
  /** Check the host at each endpoint; prints the report and returns whether all passed. */
57
79
  async function doctorHosts(endpoints) {
58
80
  let passed = true;
@@ -83,7 +105,8 @@ function doctorClient(client, endpoints, defaultEndpoint) {
83
105
  if (check.text.includes(VERSIONED_NOTE))
84
106
  versioned = true;
85
107
  };
86
- const commands = ourCommands(readConfig(hooksFile), client);
108
+ const config = readConfig(hooksFile);
109
+ const commands = ourCommands(config, client);
87
110
  const mcp = readMcpCommand(client, mcpFile);
88
111
  if (commands.size === 0 && !mcp)
89
112
  return undefined;
@@ -101,6 +124,8 @@ function doctorClient(client, endpoints, defaultEndpoint) {
101
124
  report(`hook Node ${parsed.node}`, checkNode(parsed.node));
102
125
  report(`hook entry ${parsed.entry}`, checkEntry(parsed.entry));
103
126
  }
127
+ if (client === "claude" && commands.size > 0)
128
+ report(`background wait hook in ${hooksFile}`, checkClaudeWait(config));
104
129
  if (!mcp) {
105
130
  report(`MCP server ${MCP_SERVER_NAME} in ${mcpFile}`, bad("not registered"));
106
131
  }
package/build/cli/hook.js CHANGED
@@ -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. */
@@ -101,9 +102,10 @@ const WAIT_DEADLINE_MS = (WAIT_HOOK_TIMEOUT_SEC - 10) * 1000;
101
102
  const WAIT_RETRY_CODES = new Set([...UNREACHABLE_CODES, "ECONNRESET", "EPIPE", "ENOTFOUND", "EAI_AGAIN", REQUEST_TIMEOUT_CODE]);
102
103
  /**
103
104
  * Ask the daemon until something arrives for the session (the wake), the
104
- * session becomes active again or the deadline passes (null).
105
+ * session becomes active again, the deadline passes or, checked before each
106
+ * request, `stop` says the client is gone (null).
105
107
  */
106
- export async function waitForWake(client, sessionId, configured, deadlineMs = WAIT_DEADLINE_MS) {
108
+ export async function waitForWake(client, sessionId, configured, deadlineMs = WAIT_DEADLINE_MS, stop = () => false) {
107
109
  const deadline = Date.now() + deadlineMs;
108
110
  let endpoint = await resolveEndpoint(configured);
109
111
  let delay = WAKE_RETRY_FIRST_DELAY_MS;
@@ -112,7 +114,7 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
112
114
  if (fresh)
113
115
  endpoint = await resolveEndpoint(configured, { fresh: true });
114
116
  const remaining = deadline - Date.now();
115
- if (remaining <= 0)
117
+ if (remaining <= 0 || stop())
116
118
  return null;
117
119
  let response;
118
120
  try {
@@ -138,6 +140,18 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
138
140
  }
139
141
  return null;
140
142
  }
143
+ /**
144
+ * The delay before the Codex wait tries again after the failed `codex queue`
145
+ * it waited `delay` before (the first failure waits WAKE_RETRY_FIRST_DELAY_MS),
146
+ * doubled each time, or null once it would reach WAKE_RETRY_MAX_DELAY_MS
147
+ * (about 8.5 minutes of failures): Codex does not have the conversation loaded
148
+ * (its terminal was closed, the app restarted), so the wait ends, and the
149
+ * messages, undone by the failure report, wait for the user's next prompt there.
150
+ */
151
+ export function nextQueueRetry(delay) {
152
+ const next = delay * 2;
153
+ return next >= WAKE_RETRY_MAX_DELAY_MS ? null : next;
154
+ }
141
155
  /**
142
156
  * `--wait`, started by a Stop hook when a turn ends. Claude runs it as an
143
157
  * asyncRewake hook: exit code 2 with the notification on stderr wakes the
@@ -147,7 +161,15 @@ export async function waitForWake(client, sessionId, configured, deadlineMs = WA
147
161
  * and reports to the host whether it did (WAKE_RESULT_EVENT): after a failed
148
162
  * queue the host undoes the wake and the copy waits again after a delay that
149
163
  * doubles on each failure, so the same messages wake the session on a later
150
- * try without retrying a broken `codex` in a loop.
164
+ * try without retrying a broken `codex` in a loop; once the delay reaches its
165
+ * maximum the copy ends, and the messages wait for the user's next prompt.
166
+ *
167
+ * The wait ends when the client it would wake is gone (Claude Code's
168
+ * CLAUDE_PID, or the Codex process the Stop hook passes as `--client-pid`),
169
+ * checked before each request, so a client that was killed is not kept
170
+ * listening; a wake that arrives after it is gone is reported as not delivered.
171
+ * A request is never cut short for it: the host may already have handed over a
172
+ * wake on it, which only a report can undo.
151
173
  */
152
174
  async function runWait(argv) {
153
175
  const { client, configured } = clientAndEndpoint(argv);
@@ -163,18 +185,27 @@ async function runWait(argv) {
163
185
  if (client === "codex") {
164
186
  const { entryScript } = await import("./entry.js");
165
187
  const args = [entryScript(), "hook", "codex", "--wait", "--session", sessionId, ...(argValue(argv, "--endpoint") ? ["--endpoint", configured] : [])];
166
- await startDetached(process.execPath, args);
188
+ await startDetached(process.execPath, args, hookClientPid);
167
189
  return 0;
168
190
  }
169
191
  }
170
- // Load what a wake needs before waiting: the wait lasts up to an hour, and an
192
+ const clientPid = parsePid(client === "claude" ? process.env.CLAUDE_PID : argValue(argv, "--client-pid"));
193
+ const clientGone = () => clientPid !== undefined && !processAlive(clientPid);
194
+ // Load what a wake needs before waiting: the wait lasts up to a day, and an
171
195
  // upgrade or a cleared npx cache may remove the package's files meanwhile.
172
196
  const { spawnClientSync } = client === "codex" ? await import("./client-command.js") : { spawnClientSync: undefined };
197
+ const report = (wake, ok) => new NexusClient({ endpoint: wake.endpoint })
198
+ .hook({ client, input: { hook_event_name: WAKE_RESULT_EVENT, session_id: sessionId, dispatch_id: wake.dispatchId, ok } }, HOOK_REQUEST_TIMEOUT_MS)
199
+ .then(() => true, () => false);
173
200
  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());
201
+ for (let delay = WAKE_RETRY_FIRST_DELAY_MS;; delay = nextQueueRetry(delay)) {
202
+ const wake = await waitForWake(client, sessionId, configured, deadline - Date.now(), clientGone);
176
203
  if (wake === null)
177
204
  return 0;
205
+ if (clientGone()) {
206
+ await report(wake, false);
207
+ return 0;
208
+ }
178
209
  if (!spawnClientSync) {
179
210
  await new Promise((resolve) => process.stderr.write(wake.text + "\n", () => resolve()));
180
211
  return 2;
@@ -182,10 +213,9 @@ async function runWait(argv) {
182
213
  // Never through cmd.exe: the text comes from another session and spans several lines.
183
214
  const queued = spawnClientSync(process.env.NEXUS_CODEX_COMMAND || "codex", ["queue", "--thread", sessionId, "--message", wake.text], { stdio: "ignore", windowsHide: true });
184
215
  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);
216
+ const reported = await report(wake, ok);
187
217
  // Unknown result (no report): the host keeps the wake, and waiting again would deliver nothing.
188
- if (ok || !reported || Date.now() + delay >= deadline)
218
+ if (ok || !reported || delay === null || Date.now() + delay >= deadline)
189
219
  return 0;
190
220
  await new Promise((resolve) => setTimeout(resolve, delay));
191
221
  }
@@ -196,17 +226,27 @@ async function runWait(argv) {
196
226
  * on: a process Node spawns inherits every inheritable handle of this one, and
197
227
  * when Codex runs the hook through PowerShell (`commandWindows`) those include
198
228
  * 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.
229
+ * until the hook's timeout and ends the wait copy with the hook. The copy gets
230
+ * the client process as `--client-pid`: from `clientPid` on Linux and macOS,
231
+ * found by the launching PowerShell itself on Windows.
200
232
  */
201
- async function startDetached(file, args) {
233
+ async function startDetached(file, args, clientPid) {
202
234
  if (process.platform !== "win32") {
203
- spawn(file, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
235
+ const pid = clientPid();
236
+ spawn(file, [...args, ...(pid !== undefined ? ["--client-pid", String(pid)] : [])], { detached: true, stdio: "ignore", windowsHide: true }).unref();
204
237
  return;
205
238
  }
206
239
  const literal = (s) => `'${s.replace(/'/g, "''")}'`;
207
240
  // Start-Process joins -ArgumentList with spaces, so each argument is quoted for the command line itself.
208
241
  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(",")}`;
242
+ // The client process (--client-pid) is found here, in the PowerShell that starts the copy anyway, not
243
+ // in one of its own: the nearest ancestor of this hook that is not a shell (Codex runs commandWindows
244
+ // through PowerShell). Not found: the copy runs without it.
245
+ const shells = SHELL_NAMES.map(literal).join(",");
246
+ const command = `$a = @(${args.map((a) => literal(quoted(a))).join(",")}); `
247
+ + `try { $c = ${process.ppid}; for ($i = 0; $i -lt 4; $i++) { $p = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $c); if (-not $p) { break }; `
248
+ + `if (@(${shells}) -notcontains ($p.Name -replace '[.]exe$', '').ToLower()) { $a += ${literal(quoted("--client-pid"))}, ('"' + $c + '"'); break }; $c = $p.ParentProcessId } } catch { }; `
249
+ + `Start-Process -WindowStyle Hidden -FilePath ${literal(file)} -ArgumentList $a`;
210
250
  const powershell = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
211
251
  await new Promise((resolve, reject) => {
212
252
  const launcher = spawn(powershell, ["-NoProfile", "-NonInteractive", "-Command", command], { stdio: "ignore", windowsHide: true });
package/build/contract.js CHANGED
@@ -40,8 +40,10 @@ export const HOOK_INPUT_FIELDS = contract.hookInputFields;
40
40
  */
41
41
  export const BACKGROUND_WAIT_EVENT = contract.backgroundWaitEvent;
42
42
  /**
43
- * Pseudo hook event with which the Codex background wait reports whether
44
- * `codex queue` took a wake: input `{ session_id, dispatch_id, ok }`. On
43
+ * Pseudo hook event with which a background wait reports whether it handed
44
+ * over a wake (Codex: whether `codex queue` took it; Claude Code: only a wake
45
+ * that arrived after Claude Code was gone, as `ok: false`): input
46
+ * `{ session_id, dispatch_id, ok }`. On
45
47
  * `ok: false` the host undoes the wake, so its messages and notices wake the
46
48
  * session again.
47
49
  */
@@ -58,7 +60,11 @@ export const HOOK_TIMEOUT_SEC = contract.timing.hookTimeoutSec;
58
60
  export const CODEX_SESSION_END_TIMEOUT_SEC = contract.timing.codexSessionEndTimeoutSec;
59
61
  /** HTTP timeout of a SessionEnd hook, which is not resent after a timeout so it ends within CODEX_SESSION_END_TIMEOUT_SEC. */
60
62
  export const SESSION_END_REQUEST_TIMEOUT_MS = contract.timing.sessionEndRequestTimeoutMs;
61
- /** After a failed `codex queue`, the Codex wait asks again after this delay, doubled on each failure up to the maximum. */
63
+ /**
64
+ * After a failed `codex queue`, the Codex wait asks again after the first
65
+ * delay, doubled on each failure, and gives up once it would reach the maximum
66
+ * (nextQueueRetry). The maximum also caps the wait's reconnect delay.
67
+ */
62
68
  export const WAKE_RETRY_FIRST_DELAY_MS = contract.timing.wakeRetryFirstDelayMs;
63
69
  export const WAKE_RETRY_MAX_DELAY_MS = contract.timing.wakeRetryMaxDelayMs;
64
70
  /**
@@ -69,8 +75,12 @@ export const MAX_TITLE_CHARS = contract.limits.maxTitleChars;
69
75
  /** Name the MCP server is installed under; Claude's PreToolUse matcher depends on it. */
70
76
  export const MCP_SERVER_NAME = contract.mcpServerName;
71
77
  export const CLAUDE_PRE_TOOL_MATCHER = `mcp__${MCP_SERVER_NAME}__.*`;
72
- /** Payload of a LAN discovery query datagram; the host answers only this. */
73
- export const DISCOVERY_QUERY = contract.discoveryQuery;
78
+ /**
79
+ * Payload of a LAN discovery query datagram, the host answers only this:
80
+ * discoveryQuery padded with spaces to discoveryQueryBytes, so that the reply
81
+ * is never longer than the query (no amplification for a forged source).
82
+ */
83
+ export const DISCOVERY_QUERY = contract.discoveryQuery.padEnd(contract.discoveryQueryBytes, " ");
74
84
  /** Where to get the host. */
75
85
  export const HOST_RELEASES_URL = "https://github.com/n2ns/n2n-nexus/releases";
76
86
  /** What to do when no host answers at a loopback endpoint: clients never start one themselves. */
@@ -17,8 +17,9 @@ const INSTRUCTIONS = "n2n-nexus connects this conversation with other AI session
17
17
  "show. What a message may ask of you is set by your user's own instructions; when something is missing, ask the " +
18
18
  "sender with reply. After send, end your turn instead of polling with read: the answer arrives through the hooks " +
19
19
  "after your next tool call or at the end of a turn (at your user's next prompt once the automatic-turn limit is " +
20
- "reached), and notifyWhenIdle tells you when the recipient finishes its turn (set expectsReply and wake false on a " +
21
- "message that needs no answer, and skip plain acknowledgements); listen waits for messages after each turn. " +
20
+ "reached), and notifyWhenIdle tells you when the recipient finishes its turn (every message wakes the recipient: " +
21
+ "set expectsReply false on one that needs no answer, and skip plain acknowledgements); listen waits for messages " +
22
+ "after each turn. " +
22
23
  "Direct messaging needs the n2n-nexus hooks. Separately, send_message and read_messages are the shared " +
23
24
  "global chat and meetings, not direct messages. Session ids in peers (claude:<id>, codex:<id>) are what send " +
24
25
  "takes; with the hooks, chat and meetings also record senders and unread cursors per session id " +
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": 4,
2
+ "contractVersion": 5,
3
3
  "errorBody": {
4
4
  "type": "object",
5
5
  "properties": {
@@ -21,7 +21,7 @@
21
21
  "ok": true,
22
22
  "service": "n2n-nexus",
23
23
  "version": "<host version>",
24
- "contractVersion": 4,
24
+ "contractVersion": 5,
25
25
  "sessions": {
26
26
  "online": "<number of agent sessions not offline>"
27
27
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": 4,
2
+ "contractVersion": 5,
3
3
  "clients": [
4
4
  "claude",
5
5
  "codex"
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "mcpServerName": "n2n-nexus",
16
16
  "discoveryQuery": "n2n-nexus/discover",
17
+ "discoveryQueryBytes": 512,
17
18
  "hookInputFields": [
18
19
  "hook_event_name",
19
20
  "session_id",
@@ -37,7 +38,7 @@
37
38
  "hookStopRequestTimeoutMs": 595000,
38
39
  "hookRequestTimeoutMs": 5000,
39
40
  "hookStopTimeoutSec": 600,
40
- "waitHookTimeoutSec": 3600,
41
+ "waitHookTimeoutSec": 86400,
41
42
  "hookTimeoutSec": 10,
42
43
  "codexSessionEndTimeoutSec": 3,
43
44
  "sessionEndRequestTimeoutMs": 1500,
@@ -73,6 +74,10 @@
73
74
  "messageHeader": "--- Message %s from %s (%s) at %s ---",
74
75
  "messageEnd": "--- End of message %s ---",
75
76
  "messageReplyTo": "This answers your message %s.",
77
+ "messageReplyToBroadcast": "This answers your broadcast %s.",
78
+ "broadcastHeader": "--- Broadcast %s from %s (%s) at %s, sent to %s ---",
79
+ "broadcastToAll": "every online session",
80
+ "broadcastToProject": "the online sessions working in %s",
76
81
  "messageCut": "[Cut at %d of %d characters; read returns the whole message.]",
77
82
  "replyInstruction": "Answer it with reply (messageId %s; expectsReply false for a result, true for a question).",
78
83
  "readRest": "read returns the messages that are cut or not shown here.",
@@ -83,6 +88,7 @@
83
88
  "meetingMessageHeader": "--- Meeting message #%d from %s at %s%s ---",
84
89
  "meetingMessageEnd": "--- End of meeting message #%d ---",
85
90
  "meetingNotShown": "- %d more message(s) not shown (#%d–#%d).",
91
+ "meetingEarlierUnread": "Meeting \"%s\" (%s): %d earlier message(s) of others still unread; read them with read_messages(meetingId).",
86
92
  "meetingReadRest": "read_messages(meetingId) returns the meeting messages that are cut or not shown here.",
87
93
  "meetingClosing": "Post with send_message(meetingId) only when you have something to add.",
88
94
  "sentWithUnread": "[n2n-nexus: sent while %s were unread by the sender.]",