n2n-nexus 0.12.5 → 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 +18 -0
- package/build/cli/client-process.js +72 -0
- package/build/cli/doctor.js +28 -3
- package/build/cli/hook.js +55 -15
- package/build/contract.js +15 -5
- package/build/server/nexus.js +3 -2
- package/contract/errors.json +2 -2
- package/contract/hook.json +4 -2
- package/contract/tools.json +5 -15
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/ARCHITECTURE_zh.md +8 -6
- package/docs/ASSISTANT_GUIDE.md +1 -1
- package/docs/ASSISTANT_GUIDE_zh.md +1 -1
- package/docs/CHANGELOG_zh.md +18 -0
- package/docs/INSTALL.md +1 -1
- package/docs/INSTALL_zh.md +1 -1
- package/docs/TOOLS_REFERENCE.md +7 -7
- package/docs/TOOLS_REFERENCE_zh.md +7 -7
- package/docs/USER_GUIDE.md +1 -1
- package/docs/USER_GUIDE_zh.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
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
|
+
|
|
7
25
|
## [0.12.5] - 2026-11-04
|
|
8
26
|
|
|
9
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.
|
|
@@ -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
|
+
}
|
package/build/cli/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
* `codex queue` took
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
73
|
-
|
|
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. */
|
package/build/server/nexus.js
CHANGED
|
@@ -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 (
|
|
21
|
-
"
|
|
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 " +
|
package/contract/errors.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
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":
|
|
24
|
+
"contractVersion": 5,
|
|
25
25
|
"sessions": {
|
|
26
26
|
"online": "<number of agent sessions not offline>"
|
|
27
27
|
}
|
package/contract/hook.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
2
|
+
"contractVersion": 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":
|
|
41
|
+
"waitHookTimeoutSec": 86400,
|
|
41
42
|
"hookTimeoutSec": 10,
|
|
42
43
|
"codexSessionEndTimeoutSec": 3,
|
|
43
44
|
"sessionEndRequestTimeoutMs": 1500,
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"meetingMessageHeader": "--- Meeting message #%d from %s at %s%s ---",
|
|
88
89
|
"meetingMessageEnd": "--- End of meeting message #%d ---",
|
|
89
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).",
|
|
90
92
|
"meetingReadRest": "read_messages(meetingId) returns the meeting messages that are cut or not shown here.",
|
|
91
93
|
"meetingClosing": "Post with send_message(meetingId) only when you have something to add.",
|
|
92
94
|
"sentWithUnread": "[n2n-nexus: sent while %s were unread by the sender.]",
|
package/contract/tools.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion":
|
|
2
|
+
"contractVersion": 5,
|
|
3
3
|
"tools": [
|
|
4
4
|
{
|
|
5
5
|
"name": "sync_project_assets",
|
|
@@ -1126,7 +1126,7 @@
|
|
|
1126
1126
|
{
|
|
1127
1127
|
"name": "send",
|
|
1128
1128
|
"title": "Send Direct Message",
|
|
1129
|
-
"description": "Send a direct message to one AI session of your own user (an id or name listed by peers); for a meeting or the global chat use send_message instead. The recipient gets the whole message in its notification; to answer a message you received, use reply. Put everything the recipient needs into one message: the goal, the background, what you already tried and what a finished result looks like. Then end your turn rather than polling with read: the answer reaches you through the hooks after your next tool call or at the end of a turn (at your user's next prompt once the automatic-turn limit is reached); notifyWhenIdle tells you when the recipient finishes its turn.
|
|
1129
|
+
"description": "Send a direct message to one AI session of your own user (an id or name listed by peers); for a meeting or the global chat use send_message instead. The recipient gets the whole message in its notification; to answer a message you received, use reply. Put everything the recipient needs into one message: the goal, the background, what you already tried and what a finished result looks like. Then end your turn rather than polling with read: the answer reaches you through the hooks after your next tool call or at the end of a turn (at your user's next prompt once the automatic-turn limit is reached); notifyWhenIdle tells you when the recipient finishes its turn. Every message wakes an idle recipient or continues its turn, so do not send plain acknowledgements; expectsReply false marks a message that needs no answer (a result, a notice). Needs the n2n-nexus hooks.",
|
|
1130
1130
|
"annotations": {
|
|
1131
1131
|
"readOnlyHint": false,
|
|
1132
1132
|
"destructiveHint": false,
|
|
@@ -1156,12 +1156,7 @@
|
|
|
1156
1156
|
"expectsReply": {
|
|
1157
1157
|
"type": "boolean",
|
|
1158
1158
|
"default": true,
|
|
1159
|
-
"description": "false: tell the recipient no reply is needed (for results
|
|
1160
|
-
},
|
|
1161
|
-
"wake": {
|
|
1162
|
-
"type": "boolean",
|
|
1163
|
-
"default": true,
|
|
1164
|
-
"description": "false: do not wake the recipient or hold its turn; it sees the message at its next prompt or tool call (for acknowledgements and other messages that can wait)."
|
|
1159
|
+
"description": "false: tell the recipient no reply is needed (for results and notices)."
|
|
1165
1160
|
}
|
|
1166
1161
|
},
|
|
1167
1162
|
"required": [
|
|
@@ -1333,7 +1328,7 @@
|
|
|
1333
1328
|
{
|
|
1334
1329
|
"name": "reply",
|
|
1335
1330
|
"title": "Reply to Message",
|
|
1336
|
-
"description": "Reply to a direct message or a broadcast you received (for a meeting or the global chat use send_message instead); it goes to the original sender as a direct message, and the message you answer counts as read. expectsReply
|
|
1331
|
+
"description": "Reply to a direct message or a broadcast you received (for a meeting or the global chat use send_message instead); it goes to the original sender as a direct message, and the message you answer counts as read. expectsReply works as for send, and a reply wakes the recipient like any direct message: do not reply only to acknowledge.",
|
|
1337
1332
|
"annotations": {
|
|
1338
1333
|
"readOnlyHint": false,
|
|
1339
1334
|
"destructiveHint": false,
|
|
@@ -1357,12 +1352,7 @@
|
|
|
1357
1352
|
"expectsReply": {
|
|
1358
1353
|
"type": "boolean",
|
|
1359
1354
|
"default": true,
|
|
1360
|
-
"description": "false: tell the recipient no reply is needed (for results
|
|
1361
|
-
},
|
|
1362
|
-
"wake": {
|
|
1363
|
-
"type": "boolean",
|
|
1364
|
-
"default": true,
|
|
1365
|
-
"description": "false: do not wake the recipient or hold its turn; it sees the message at its next prompt or tool call (for acknowledgements and other messages that can wait)."
|
|
1355
|
+
"description": "false: tell the recipient no reply is needed (for results and notices)."
|
|
1366
1356
|
}
|
|
1367
1357
|
},
|
|
1368
1358
|
"required": [
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -88,7 +88,7 @@ An idle conversation is not running, so hooks alone can only reach it when its u
|
|
|
88
88
|
|
|
89
89
|
```
|
|
90
90
|
turn ends ──▶ Stop hook (announces unread, or listens)
|
|
91
|
-
└─▶ Stop hook --wait ──▶ POST /api/hook {BackgroundWait} (up to 590 s per request, repeated for up to
|
|
91
|
+
└─▶ Stop hook --wait ──▶ POST /api/hook {BackgroundWait} (up to 590 s per request, repeated for up to 24 h)
|
|
92
92
|
│ session shows as "listening"
|
|
93
93
|
├─ new message ─▶ { wake: notification, dispatchId }
|
|
94
94
|
│ Claude Code: asyncRewake hook exits 2, the notification starts a turn
|
|
@@ -100,14 +100,16 @@ turn ends ──▶ Stop hook (announces unread, or listens)
|
|
|
100
100
|
└─ time runs out or host shuts down ─▶ { timeout: true } (ask again)
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
The
|
|
103
|
+
The wait ends when the client it would wake is gone: before each request it checks the Claude Code process (`CLAUDE_PID`) or the Codex process that ran the Stop hook (its nearest ancestor that is not a shell, passed to the detached copy as `--client-pid`), so a client that was killed or crashed without a `SessionEnd` stays `listening` for at most one more request (590 s), not until the wait's deadline. A request in progress is never cut short for it, since the host may already have handed over a wake on it; a wake that arrives once the client is gone is reported with `WakeResult` `ok: false` (for Claude Code too) and undone. Without that process id (a client that sets no `CLAUDE_PID`, an ancestor that cannot be read) the wait runs without the check.
|
|
104
104
|
|
|
105
|
-
The wait
|
|
105
|
+
The background wait retries temporary connection failures within its original 24-hour deadline; with `discover`, it looks up the host again. A normal cancellation (`output: null`) still ends the wait. Host and client must both include this recovery behavior for graceful host restarts to resume waiting.
|
|
106
|
+
|
|
107
|
+
The wait never delivers while the session is `working` or while a listening Stop hook is waiting, so a message is announced once. When `codex queue` fails (an error or a non-zero exit), the Codex wait reports `ok: false` and the host undoes that wake: the messages and notices it announced count as not announced again, the session leaves `working` and the automatic turn is not counted, and the failed wake is logged in `host.log` (the session, the wake and how many messages and notices are delivered again); the wait then asks again after 1 s, doubling after each failure, and delivers them again. After ten failed tries (1 s to 256 s apart, about 8.5 minutes) it ends: Codex does not have the conversation loaded (a terminal closed without `SessionEnd` leaves its app-server, and so the hook's client process, running), and the messages stay unread for the user's next prompt there. Any other event of the session before the report means the wake was delivered, and the report is ignored. Verified on Windows with the Claude desktop app and the Codex desktop app (2026-10-23): both directions wake an idle conversation within seconds. Verified again on 2026-11-10 with Claude Code and the Codex CLI under WSL and the Codex desktop app on Windows, nobody typing: a message that needs no reply wakes the recipient in both directions without acknowledgement loops; a message to a working recipient is handled in its turn, without a second turn; after a host restart the waits reconnect within 7 s; after more than an hour idle both directions still wake; and a message to a Claude Code conversation killed with `kill -9` stays unread while its wait ends. Known limits:
|
|
106
108
|
|
|
107
109
|
- Codex starts a queued turn only for a conversation loaded in the running Codex app. After Codex restarts, a conversation is loaded once the user opens it.
|
|
108
110
|
- The Codex background wait ends when the Codex app quits, and starts again only when the conversation ends a turn. Messages sent meanwhile stay unread in the host (not in Codex's own queue) until the user next prompts that conversation.
|
|
109
|
-
- A
|
|
110
|
-
- Every AI reply wakes the recipient again, so sessions can work through each other's requests without anyone typing. To cap that, set `maxAutoTurns` in `settings.json` (default 0, no limit): the host then counts automatic turns (wakes, listening Stops, Stops held open for messages) since the user's last prompt and stops after that many; a prompt within a minute of a wake is the woken turn starting and keeps the count. Messages past the limit wait until the user prompts. Meeting news is not counted and is not limited: a meeting runs until it is ended
|
|
111
|
+
- A conversation is reachable for a day after its last turn (`waitHookTimeoutSec`: the Claude Code hook timeout, and the deadline of the Codex wait); after that, messages wait until its user prompts.
|
|
112
|
+
- Every AI reply wakes the recipient again, so sessions can work through each other's requests without anyone typing. To cap that, set `maxAutoTurns` in `settings.json` (default 0, no limit): the host then counts automatic turns (wakes, listening Stops, Stops held open for messages) since the user's last prompt and stops after that many; a prompt within a minute of a wake is the woken turn starting and keeps the count. Messages past the limit wait until the user prompts. Meeting news is not counted and is not limited: a meeting runs until it is ended.
|
|
111
113
|
- The automatic turn count tells the woken turn from the user by time alone. If a wake does not start a turn (a Codex conversation not loaded) and the user prompts within that minute, that prompt keeps the count; the user's next prompt resets it. If a woken turn starts more than a minute late (a Codex conversation the user opens later), its prompt resets the count. Neither loses a message or lets sessions loop without a user.
|
|
112
114
|
|
|
113
115
|
---
|
|
@@ -194,7 +196,7 @@ The Go module is `github.com/n2ns/n2n-nexus` at the repository root, so the host
|
|
|
194
196
|
|
|
195
197
|
**Conversation state machine**: `agents.Agents` is the in-process coordinator of one host. It holds all in-memory conversation state — held listening Stops, background waits, automatic-turn counts, wake times — behind one mutex; every hook and direct-message tool call runs under it, and whoever stores a message or notice delivers it to a waiting hook before releasing the lock. At startup it resets sessions a previous host left `listening` to `idle`. Shutting down answers held background waits with `{ "output": { "timeout": true } }` so they reconnect, and held listening Stops with `{ "output": null }`, before the server closes. Time comes from a `Clock`: the system clock in release builds, a clock the conformance tests set through `POST /test/clock` in the `testclock` build.
|
|
196
198
|
|
|
197
|
-
**Discovery**: a host whose `host` is not a loopback address answers `n2n-nexus/discover` datagrams on UDP `discoveryPort` with `{"service":"n2n-nexus","version","host","port"}`. Clients with `NEXUS_ENDPOINT=discover` broadcast the query on `NEXUS_DISCOVERY_PORT`, use the first answer and cache it in `~/.n2n-nexus/discovered-endpoint`.
|
|
199
|
+
**Discovery**: a host whose `host` is not a loopback address answers `n2n-nexus/discover` datagrams on UDP `discoveryPort` with `{"service":"n2n-nexus","version","host","port"}`. The query is padded with spaces to `discoveryQueryBytes` (512), and the host answers only that exact datagram and never with a reply longer than it: the source address is not verified, so a longer reply would let a forged query aim more bytes at another machine. Clients with `NEXUS_ENDPOINT=discover` broadcast the query on `NEXUS_DISCOVERY_PORT`, use the first answer and cache it in `~/.n2n-nexus/discovered-endpoint`.
|
|
198
200
|
|
|
199
201
|
---
|
|
200
202
|
|
package/docs/ARCHITECTURE_zh.md
CHANGED
|
@@ -88,7 +88,7 @@ Stop ◀── decision: block ◀─┘ 每次结
|
|
|
88
88
|
|
|
89
89
|
```
|
|
90
90
|
一轮结束 ──▶ Stop hook(提醒未读,或进入 listen 等待)
|
|
91
|
-
└─▶ Stop hook --wait ──▶ POST /api/hook {BackgroundWait}(每次最多 590 秒,循环最长
|
|
91
|
+
└─▶ Stop hook --wait ──▶ POST /api/hook {BackgroundWait}(每次最多 590 秒,循环最长 24 小时)
|
|
92
92
|
│ 等待期间会话显示为 "listening"
|
|
93
93
|
├─ 有新消息 ─▶ { wake: 通知, dispatchId }
|
|
94
94
|
│ Claude Code:asyncRewake hook 以退出码 2 结束,通知开启新的一轮
|
|
@@ -99,14 +99,16 @@ Stop ◀── decision: block ◀─┘ 每次结
|
|
|
99
99
|
└─ 时间到或 host 关闭 ─▶ { timeout: true }(再次请求)
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
要叫醒的客户端不在了,等待就结束:每次请求前检查 Claude Code 进程(`CLAUDE_PID`),或运行 Stop hook 的 Codex 进程(最近一个不是 shell 的祖先进程,以 `--client-pid` 传给独立后台进程),所以客户端被强制结束或崩溃、没有发出 `SessionEnd` 时,会话最多再显示一次请求的时间(590 秒)为 `listening`,而不是一直到等待期限。进行中的请求不会因此被中断,因为 host 可能已经在这次请求上交出了唤醒;客户端不在后才到的唤醒,用 `WakeResult` `ok: false` 回报(Claude Code 也一样)并被撤销。拿不到这个进程 ID 时(客户端没有设置 `CLAUDE_PID`、读不到祖先进程),等待照常进行,不做这项检查。
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
后台等待会在原有的 24 小时时限内重试临时连接故障;使用 `discover` 时重新查找 host。正常取消(`output: null`)仍结束等待。host 与客户端都需要包含这项恢复行为,才能在 host 正常重启后继续等待。
|
|
105
|
+
|
|
106
|
+
会话处于 `working`,或者 listen 模式的 Stop hook 正在等待时,后台等待不会送达,所以同一条消息只提醒一次。`codex queue` 失败(出错或退出码非 0)时,Codex 的后台等待回报 `ok: false`,host 撤销这次唤醒:它提醒过的消息和通知重新算作未提醒,会话退出 `working`,这一轮不计入自动轮次,失败的唤醒记入 `host.log`(会话、唤醒 ID、再次送达的消息和通知各有几条);随后等待在 1 秒后重新请求(每失败一次间隔加倍),再次送达它们。连续失败 10 次(间隔 1 秒到 256 秒,共约 8.5 分钟)后等待结束:Codex 没有加载该对话(终端没发出 `SessionEnd` 就被关闭时,它的 app-server,也就是 hook 的客户端进程,仍在运行),消息保持未读,留到用户下次在该对话里输入时再提醒。回报到达前该会话已有其他事件,说明唤醒已送达,回报被忽略。已在 Windows 上用 Claude 桌面应用和 Codex 桌面应用实测(2026-10-23):两个方向都能在数秒内叫醒空闲的对话。2026-11-10 又用 WSL 中的 Claude Code、Codex CLI 和 Windows 上的 Codex 桌面应用实测,全程无人输入:无需回复的消息在两个方向都能叫醒收件方,没有确认循环;发给正在工作的收件方的消息在它这一轮里处理,不另起一轮;host 重启后等待在 7 秒内重连;闲置超过一小时后两个方向仍能叫醒;被 `kill -9` 结束的 Claude Code 对话收到的消息保持未读,它的等待随之结束。已知限制:
|
|
105
107
|
|
|
106
108
|
- Codex 只会为运行中的 Codex 应用里已加载的对话启动排队的一轮。Codex 重启后,用户点开对话才会加载。
|
|
107
109
|
- Codex 的后台等待会随 Codex 应用退出而结束,要等该对话再结束一轮才会重新启动。这期间发来的消息留在 host 里为未读(不进入 Codex 自己的队列),直到用户下次在该对话里发消息。
|
|
108
|
-
- Claude Code
|
|
109
|
-
- 每次 AI 回复都会再次叫醒对方,所以会话之间不用有人输入就能一轮轮处理对方的请求。要设上限,就在 `settings.json` 里设置 `maxAutoTurns`(默认 0,不限制):host 会统计从用户上次输入以来的自动轮次(被叫醒、listen
|
|
110
|
+
- 对话在最后一轮结束后一天内可被叫醒(`waitHookTimeoutSec`:Claude Code 的 hook 超时时间,也是 Codex 后台等待的期限);超过后,消息要等用户下次输入。
|
|
111
|
+
- 每次 AI 回复都会再次叫醒对方,所以会话之间不用有人输入就能一轮轮处理对方的请求。要设上限,就在 `settings.json` 里设置 `maxAutoTurns`(默认 0,不限制):host 会统计从用户上次输入以来的自动轮次(被叫醒、listen 等待、因消息被拦下不结束),达到这个数后不再自动继续;叫醒后一分钟内的那次用户提交视为被叫醒的那一轮开始,不清零计数。超过上限的消息会等用户下次输入时再提醒。会议新消息不计数,也不受上限限制:会议一直进行到被结束为止。
|
|
110
112
|
- 自动轮次的计数只按时间区分"被叫醒的那一轮"和"用户输入"。如果一次唤醒没有真正开始新的一轮(例如 Codex 对话没加载),而用户在那一分钟内亲自输入,这次输入不清零计数,用户下一次输入才会清零。如果被叫醒的那一轮晚于一分钟才开始(例如用户之后才点开的 Codex 对话),它的提交会清零计数。两种情况都不会丢消息,也不会让会话在无人参与时无限循环。
|
|
111
113
|
|
|
112
114
|
---
|
|
@@ -193,7 +195,7 @@ Go 模块是仓库根目录的 `github.com/n2ns/n2n-nexus`,所以 host 直接
|
|
|
193
195
|
|
|
194
196
|
**会话状态机**:`agents.Agents` 是一个 host 的进程内协调者。它把全部内存中的对话状态——挂起的 listening Stop、后台等待、自动轮次计数、唤醒时间——放在一把互斥锁后面;每个 hook 和直接消息工具调用都在锁内执行,存下消息或通知的一方在释放锁之前就把它交给正在等待的 hook。启动时把上一个 host 留下的 `listening` 会话复位为 `idle`。关闭时先用 `{ "output": { "timeout": true } }` 答复挂起的后台等待,让客户端重连;挂起的 listening Stop 仍返回 `{ "output": null }`,然后再关闭服务器。时间来自 `Clock`:正式版本用系统时间,`testclock` 构建中由一致性测试通过 `POST /test/clock` 设置。
|
|
195
197
|
|
|
196
|
-
**发现**:`host` 不是回环地址的 host 在 UDP `discoveryPort` 上应答 `n2n-nexus/discover`,回复 `{"service":"n2n-nexus","version","host","port"}
|
|
198
|
+
**发现**:`host` 不是回环地址的 host 在 UDP `discoveryPort` 上应答 `n2n-nexus/discover`,回复 `{"service":"n2n-nexus","version","host","port"}`。查询用空格补齐到 `discoveryQueryBytes`(512 字节),host 只应答正好这样的数据报,回复也从不比它长:源地址未经验证,回复更长就会让伪造的查询把更多字节引向另一台机器。`NEXUS_ENDPOINT=discover` 的客户端在 `NEXUS_DISCOVERY_PORT` 上广播查询,使用第一个应答并缓存在 `~/.n2n-nexus/discovered-endpoint`。
|
|
197
199
|
|
|
198
200
|
---
|
|
199
201
|
|
package/docs/ASSISTANT_GUIDE.md
CHANGED
|
@@ -62,7 +62,7 @@ In Claude Code and Codex with the n2n-nexus hooks installed, every conversation
|
|
|
62
62
|
1. `peers()` lists the other conversations (name, title, client, working directory, machine: hostname, platform, environment such as the WSL distribution, UTC offset and label, note, status) and your own profile; sessions on different machines or WSL distributions can share a working directory, so tell them apart by the machine; your client's own agent list (such as Claude Code's `ListAgents`) does not show them. The title comes from your client; use `set_profile({ name, note, title, label })` so others can tell what you work on and which machine you are (`label`, such as `mac-air`, replaces the hostname in what others see).
|
|
63
63
|
2. `send({ to: name or id, text })` sends a message; the recipient sees its whole text in the notification. Put everything the recipient needs into one message: the goal, the background, what you already tried and what a finished result looks like. Then end your turn instead of polling with `read`: the answer reaches you through the hooks after your next tool call or at the end of a turn. Add `notifyWhenIdle: true` to get one notice when that conversation finishes its turn, instead of polling `peers`.
|
|
64
64
|
3. You do not poll for messages. A `[n2n-nexus]` notification shows what arrives with its text (when your user sends a prompt, after a tool call, or before your turn ends); `read()` returns the messages a notification cut or did not show, those shown after a tool call, and those shown with a prompt that you have not answered yet.
|
|
65
|
-
4. Answer with `reply({ messageId, text })`; it goes to the original sender. For a message that needs no answer (a result, a notice), set `expectsReply: false`;
|
|
65
|
+
4. Answer with `reply({ messageId, text })`; it goes to the original sender. For a message that needs no answer (a result, a notice), set `expectsReply: false`; it still wakes the recipient, like every direct message. Do not send plain acknowledgements.
|
|
66
66
|
5. To tell every conversation that is online now, call `broadcast({ text })`; `project` limits it to the conversations working in that directory or below it. It is fire and forget: you learn nothing about who got or read it, conversations that are offline or start later never get it, a broadcast delivered is not a broadcast read, and there is no deduplication: calling it again, even after a lost result, sends it again. It wakes only conversations that are listening for messages; a working or idle one sees it at its next tool call or prompt. By default it needs no reply; set `expectsReply: true` only when you need answers. A broadcast you receive shows its sender in a `--- Broadcast <id> from ... ---` header; answer it, when asked, with `reply` on that id, which goes to its sender alone. A reply to your broadcast names its `broadcastId` in `replyTo`.
|
|
67
67
|
6. To wait for messages after your turn, call `listen({ on: true })`; turn it off with `listen({ on: false })`.
|
|
68
68
|
|
|
@@ -62,7 +62,7 @@ upload_project_asset(projectId, fileName, base64Content) → 上传二进制文
|
|
|
62
62
|
1. `peers()` 列出其他对话(名字、标题、客户端、工作目录、机器:主机名、平台、环境(如 WSL 发行版)、UTC 偏移和标签、备注、状态)和你自己的资料;不同机器或不同 WSL 发行版上的会话可能工作目录一样,要靠机器区分;客户端自带的代理列表(如 Claude Code 的 `ListAgents`)看不到它们。标题来自你的客户端;用 `set_profile({ name, note, title, label })` 让别人知道你在做什么、在哪台机器上(`label` 如 `mac-air`,别人看到时取代主机名)。
|
|
63
63
|
2. `send({ to: 名字或 ID, text })` 发消息;对方在通知里看到完整正文。对方需要的一切都写进一条消息:目标、背景、已经试过什么、做完是什么样子。发完就结束这一轮,不要用 `read` 轮询:回复会在你下一次工具调用后或本轮结束时由 hooks 送到。加上 `notifyWhenIdle: true`,对方这一轮结束时你会收到一次通知,不用反复查 `peers`。
|
|
64
64
|
3. 不需要主动查收。消息到了会有 `[n2n-nexus]` 通知,连同正文一起显示(用户发来新消息时、工具调用后、本轮结束前);通知截断或没显示的消息、工具调用后显示的消息,以及随提示词显示但你还没回复的消息,用 `read()` 读取。
|
|
65
|
-
4. 用 `reply({ messageId, text })` 回复,会发给原发送方。不需要对方回复的消息(结果、通知)设 `expectsReply: false
|
|
65
|
+
4. 用 `reply({ messageId, text })` 回复,会发给原发送方。不需要对方回复的消息(结果、通知)设 `expectsReply: false`;它和每条直接消息一样会叫醒对方。不要发单纯的确认收到。
|
|
66
66
|
5. 要通知当前在线的所有对话,调用 `broadcast({ text })`;`project` 只发给工作目录等于该路径或在其下的对话。它发出即结束:你不知道谁收到或读了,离线或之后才开始的对话永远收不到,送到不等于被读了;也没有去重:再调用一次(包括结果丢失后重试)就再发一次。它只叫醒正在等待消息(listening)的会话;工作中或空闲的会话在下次调用工具或输入时看到。默认无需回复;只在确实需要回答时设 `expectsReply: true`。收到的广播以 `--- Broadcast <id> from ... ---` 开头,显示发送方;要求回复时对这个 id 用 `reply` 回答,只发给发送方。别人对你的广播的回复,`replyTo` 就是该 `broadcastId`。
|
|
67
67
|
6. 想在本轮结束后继续等消息,调用 `listen({ on: true })`;用 `listen({ on: false })` 关闭。
|
|
68
68
|
|
package/docs/CHANGELOG_zh.md
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
本项目的所有重大变更都将记录在此文件中。
|
|
6
6
|
|
|
7
|
+
## [0.12.6] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
同时升级 host 和客户端(契约版本 5),然后重新运行 `n2n-nexus install <client>`,让后台等待 hook 用上新的超时时间。数据库原地升级:保留 `nexus.db`。升级后的数据库旧 host 不能打开。
|
|
10
|
+
|
|
11
|
+
### 变更
|
|
12
|
+
|
|
13
|
+
- 每条私信都会叫醒收件方:`send` 和 `reply` 不再接受 `wake`,发送方不能再让消息不叫醒空闲的对话、或不在正在工作的对话结束这一轮时拦住它。`expectsReply: false` 只表示无需回复。仍然不要发单纯的确认收到。广播不变。
|
|
14
|
+
- 叫醒空闲对话的后台等待在最后一轮结束后最长持续一天,不再是一小时(`waitHookTimeoutSec` 86400):即 Claude Code `--wait` hook 的超时时间和 Codex 后台等待的期限。
|
|
15
|
+
|
|
16
|
+
### 修复
|
|
17
|
+
|
|
18
|
+
- 客户端被强制结束或崩溃(没有 `SessionEnd`)后,它的后台等待不再让对话一直显示为 `listening`,也不再替它领走唤醒:等待在每次请求前(间隔最长 590 秒)检查 Claude Code(`CLAUDE_PID`)或运行 Stop hook 的 Codex 进程是否还在,不在就结束;之后才到的唤醒回报为未送达,host 会再次提醒这些消息,而不是把它们标为已读。以前,带着后台等待被强制结束的 Claude Code 对话会丢掉叫醒它的那条消息。
|
|
19
|
+
- Codex 的后台等待在 `codex queue` 连续失败 10 次(约 8.5 分钟)后停止重试,不再一直试到期限:这说明 Codex 没有加载该对话;期限改为一天后,被关闭的终端(其 app-server 仍在运行)否则会整天每 5 分钟重试一次。消息保持未读,留到用户下次输入时再提醒。
|
|
20
|
+
- `n2n-nexus doctor claude` 会报告超时时间与客户端不一致(旧版 install 留下的)或缺失的后台等待 hook。
|
|
21
|
+
- 既没有对话身份、也没有实例的工具调用(`POST /api/tools/call` 不带 `instanceId`,也没装 hooks)会被所有以某个身份行事的工具拒绝:`send_message`、`start_meeting`、`end_meeting`、`archive_meeting`、`reopen_meeting`、`join_meeting`、`leave_meeting` 和 `stance`,与 `read_messages` 原有的做法一致。以前,所有这样的调用方共用同一个身份 `unknown`,共用一个成员记录和一个已读游标。MCP 适配器总会带上 `instanceId`。
|
|
22
|
+
- 工具调用后的提醒里出现过的会议消息(那个上下文可能属于子代理)不会再被对话漏掉:成员在下次用户输入时会被告知还有几条之前的他人消息未读,active 成员的本轮在结束时还会为此再继续一次,与之前仍未读的私信相同。
|
|
23
|
+
- 局域网发现不再放大流量:查询补齐到 512 字节(`discoveryQueryBytes`),host 只应答这样的数据报,回复也从不比它长。以前 18 字节的查询会换来约 70 字节的回复,发往未经验证的源地址。旧客户端的查询得不到应答,所以契约版本升为 5。
|
|
24
|
+
|
|
7
25
|
## [0.12.5] - 2026-11-04
|
|
8
26
|
|
|
9
27
|
升级 host 即可;客户端不需要为这些工具升级(契约版本 4)。数据库原地升级:保留 `nexus.db`。升级后的数据库旧 host 不能打开。
|
package/docs/INSTALL.md
CHANGED
|
@@ -134,7 +134,7 @@ IPv6 discovery keeps the HTTP address family: a host bound to `::` on this machi
|
|
|
134
134
|
|
|
135
135
|
## 7. Check the installation
|
|
136
136
|
|
|
137
|
-
`n2n-nexus doctor [claude|codex]` checks that the Node path and the entry script in the installed hook commands, and the Node path and npx in the MCP command, still exist and run, that Codex trusts the Codex hooks (asking Codex itself), and that the host they talk to answers with the client's contract version; it exits with code 1 when a check fails. It checks both the hooks' endpoints and the MCP registration's `env.NEXUS_ENDPOINT`; one healthy endpoint cannot hide a failure at the other.
|
|
137
|
+
`n2n-nexus doctor [claude|codex]` checks that the Node path and the entry script in the installed hook commands, and the Node path and npx in the MCP command, still exist and run, that Claude Code's background wait hook has this client's timeout (an older install wrote a shorter one), that Codex trusts the Codex hooks (asking Codex itself), and that the host they talk to answers with the client's contract version; it exits with code 1 when a check fails. It checks both the hooks' endpoints and the MCP registration's `env.NEXUS_ENDPOINT`; one healthy endpoint cannot hide a failure at the other.
|
|
138
138
|
|
|
139
139
|
## 8. Upgrading
|
|
140
140
|
|
package/docs/INSTALL_zh.md
CHANGED
|
@@ -134,7 +134,7 @@ IPv6 发现会保持 HTTP 的地址族:本机监听 `::` 时使用 `[::1]`,
|
|
|
134
134
|
|
|
135
135
|
## 7. 检查安装
|
|
136
136
|
|
|
137
|
-
`n2n-nexus doctor [claude|codex]` 检查已安装的 hook 命令里的 Node 路径与入口脚本、MCP 命令里的 Node 路径与 npx 是否仍然存在、能否运行,Codex 是否信任 Codex 的 hooks(直接问 Codex),以及它们连接的 host 是否应答、协议版本是否与客户端一致,有检查失败时以退出码 1 退出。检查会同时读取 hooks 的地址和 MCP 注册中的 `env.NEXUS_ENDPOINT`,一方连接正常不会掩盖另一方的连接故障。
|
|
137
|
+
`n2n-nexus doctor [claude|codex]` 检查已安装的 hook 命令里的 Node 路径与入口脚本、MCP 命令里的 Node 路径与 npx 是否仍然存在、能否运行,Claude Code 的后台等待 hook 的超时时间是否与客户端一致(旧版 install 写入的更短),Codex 是否信任 Codex 的 hooks(直接问 Codex),以及它们连接的 host 是否应答、协议版本是否与客户端一致,有检查失败时以退出码 1 退出。检查会同时读取 hooks 的地址和 MCP 注册中的 `env.NEXUS_ENDPOINT`,一方连接正常不会掩盖另一方的连接故障。
|
|
138
138
|
|
|
139
139
|
## 8. 升级
|
|
140
140
|
|
package/docs/TOOLS_REFERENCE.md
CHANGED
|
@@ -150,9 +150,9 @@ These seven tools are direct messages between conversations. Global chat and mee
|
|
|
150
150
|
| Tool | Parameters | Result and behavior |
|
|
151
151
|
| --- | --- | --- |
|
|
152
152
|
| `peers` | `includeOffline?: boolean = false` | `{ self: Profile, peers: Profile[] }`; the other sessions that are not offline, most recent first. With `includeOffline`, also the offline ones active in the last 24 hours. |
|
|
153
|
-
| `send` | `to: string`, `text: string` (1–32000 chars), `notifyWhenIdle?: boolean = false`, `expectsReply?: boolean = true
|
|
153
|
+
| `send` | `to: string`, `text: string` (1–32000 chars), `notifyWhenIdle?: boolean = false`, `expectsReply?: boolean = true` | `{ messageId, to: { id, name, status } }`. `to` is a session ID (any status) or the name of a session that is not offline; you cannot message yourself. Offline recipients get the message when they resume. With `notifyWhenIdle`, you get one notice when the recipient next ends its turn, starts listening or goes offline (the subscription lapses after 24 hours). `expectsReply: false` tells the recipient no reply is needed. Every direct message wakes an idle recipient or holds its turn at its end, whether or not it expects a reply: the sender does not decide, so plain acknowledgements are not sent. |
|
|
154
154
|
| `read` | `count?: integer = 10` (1–50) | `{ messages: [{ messageId, kind, from: { id, name }, scope, replyTo, replyToKind, text, createdAt, expectsReply }], remaining }`; unread messages and broadcasts oldest first, marked read. `kind` is `direct` or `broadcast`; for a broadcast `messageId` is its `broadcastId` and `scope` the directory it was limited to (`null` for every online session, and for a direct message). `replyTo` is the message or broadcast this one answers and `replyToKind` says which (`direct` or `broadcast`); both `null` when it answers nothing. |
|
|
155
|
-
| `reply` | `messageId: string`, `text: string` (1–32000 chars), `expectsReply?: boolean = true
|
|
155
|
+
| `reply` | `messageId: string`, `text: string` (1–32000 chars), `expectsReply?: boolean = true` | Same result as `send`. `messageId` is a direct message or a broadcast you received (its ID is in the notification and in `read`); the reply goes to its sender as a direct message with `replyTo` set to it, and the message or broadcast you answer is marked read. |
|
|
156
156
|
| `broadcast` | `text: string` (1–32000 chars), `project?: string` (1–1000 chars), `expectsReply?: boolean = false` | `{ broadcastId }`. A notice to every session that is not offline at the moment of the call, yourself excepted; with `project`, only the sessions whose working directory is that path or below it (whole path components, `/` or `\`, compared as written, so the same repository under WSL and Windows does not match). It is fire and forget, like a network broadcast: the host picks the recipients and delivers it, and the sender learns nothing about who got or read it, not even that nobody was online; a broadcast delivered to a session is not a broadcast read, and it may expire unread. Offline sessions and sessions that start later never get it. The host stores the broadcast once, with one delivery per recipient; every recipient sees it under the same ID, the `broadcastId`, as a message of kind `broadcast` from the sender. A recipient that is `listening` (in listen mode or in a background wait) is woken with it at once; a `working` one is not interrupted (its turn is not held at its end) and sees it after its next tool call, and an `idle` one at its next prompt or tool call. There is no `wake` argument. By default it needs no reply; with `expectsReply: true` each recipient is asked to answer with `reply` on the `broadcastId`, which reaches the sender alone as a direct message whose `replyTo` is that `broadcastId`. Calling it again sends it again: there is no deduplication, so a call whose result was lost may send the same broadcast twice. |
|
|
157
157
|
| `listen` | `on: boolean` | `{ listening }`. While on, each time the conversation ends a turn its Stop hook waits up to 590 seconds and continues the conversation when a message or notice arrives. In Claude Code, pressing Esc ends one wait. |
|
|
158
158
|
| `set_profile` | `name?: string` (1–48 of `A–Z a–z 0–9 . _ @ # -`), `note?: string` (≤ 200 chars), `title?: string` (≤ 80 chars), `label?: string` (≤ 32 chars); at least one | `{ profile }`. Names are unique among sessions that are not offline. A `title` you set replaces the one from your client until you set it to `""`. A `label` names the machine you run on (such as `mac-air`) and is shown in place of the hostname; `""` removes it. |
|
|
@@ -164,11 +164,11 @@ Delivery. The recipient's hooks announce new messages with their whole text: eac
|
|
|
164
164
|
| Client event | What the recipient sees |
|
|
165
165
|
| --- | --- |
|
|
166
166
|
| Conversation started | Its own name and ID, the other sessions that are not offline (up to five listed), which of them can answer right away, how to use `peers`, `send` and `read`, that the client's own agent list does not show these sessions, and the collaboration rules: what a message may ask is set by the user's own instructions, with questions about it going to the sender; work handed on goes in one message with everything the recipient needs, and the sender then ends its turn instead of polling with `read`; a task is answered once with its result, or with what stopped it and the one thing that would let it go on; no plain acknowledgements, and no task passed back to its sender. |
|
|
167
|
-
| Prompt submitted | New messages (up to five shown), the count of unread ones announced before, pending notices and meeting
|
|
167
|
+
| Prompt submitted | New messages (up to five shown), the count of unread ones announced before, pending notices, meeting news and, per meeting, the count of earlier messages of others still unread. The prompt that starts a woken turn adds only what arrived after the wake. |
|
|
168
168
|
| After each tool call | Only messages, notices and meeting news not announced yet. |
|
|
169
|
-
| Turn about to end | If
|
|
169
|
+
| Turn about to end | If a direct message, or a broadcast delivered while the session was listening, is unread, a notice is pending, or a meeting the session takes part in actively has news or earlier messages of others still unread, the turn continues once with the announcement. |
|
|
170
170
|
| Listening | The wait ends as soon as something arrives that may wake the session, and the conversation continues with it. |
|
|
171
|
-
| Idle (turn ended) | A background wait started by the Stop hook (`n2n-nexus hook <client> --wait`, up to
|
|
171
|
+
| Idle (turn ended) | A background wait started by the Stop hook (`n2n-nexus hook <client> --wait`, up to a day) starts a new turn with the announcement: Claude Code through an `asyncRewake` hook (exit code 2), Codex through `codex queue --thread <session id>` (`NEXUS_CODEX_COMMAND` overrides the `codex` command). In an unattended Claude session (`claude -p`, `CLAUDE_CODE_SESSION_ATTENDED=0`) the hook returns at once, since `claude -p` waits for it before exiting. Meanwhile the session shows as `listening`; any new activity in the conversation ends the wait. Codex starts the queued turn only for a conversation loaded in the running Codex app, and the wait ends when Codex quits; when `codex queue` fails, the wake is undone and the wait delivers the same messages again after a delay (1 s, doubling); after ten failed tries (about 8.5 minutes: Codex does not have the conversation loaded) the wait ends and the messages wait for the user's next prompt there. |
|
|
172
172
|
| Automatic turn limit | Waking, a listening Stop and a Stop held open for direct messages or their notices each count as an automatic turn; meeting news and meeting notices do not count and are not limited; the announcement says `Automatic turn n of N`. With `maxAutoTurns` set (host `settings.json`; default 0, no limit, and no count in the announcement), after that many of them since the user's last prompt, messages are still stored but no longer wake or hold the conversation; the user's next prompt announces them and starts counting again. A prompt within a minute of a wake is the woken turn starting and does not reset the count. |
|
|
173
173
|
|
|
174
174
|
Unread messages expire after 24 hours; the sender then gets a notice. An unread broadcast expires after 24 hours too, and nobody is told. Notices also report `notifyWhenIdle` results.
|
|
@@ -216,7 +216,7 @@ Behavior:
|
|
|
216
216
|
- A conversation's first unread read of global chat starts at the newest message: it returns nothing and a `note`; read earlier messages with `latest` or `afterId`. Its first read of a meeting starts at the meeting's first message.
|
|
217
217
|
- History mode moves no cursor, includes your own messages and needs no identity.
|
|
218
218
|
- Result: `{ source: "global" | "meeting", mode: "unread" | "history", identity?, meetingId?, messages: [{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp }], count, note? }`. `from` is the sender's session ID (`senderName` is its name, usable with `send`, or `user's seat (via <name>)` for a message of the user's seat, and `senderMachine` its machine as `<label or hostname> · <platform>[ <environment>]`, both as they were when the message was written, so they stay after the session is cleaned up) or instance ID (`senderName` and `senderMachine` null); `senderBackfilled` is true for a message written before the host stored them, whose name and machine were filled in from the session as it was when the database was upgraded; `category` is null for a message without one. A meeting message's `id` is its number in the meeting, from 1; global chat messages have their own ids.
|
|
219
|
-
-
|
|
219
|
+
- Without hooks, every call that acts as someone needs the calling instance: `send_message`, `read_messages` in unread mode, `start_meeting`, `end_meeting`, `archive_meeting`, `reopen_meeting`, `join_meeting`, `leave_meeting` and `stance`. A call without `instanceId` fails with `<tool> needs the instanceId field of the POST /api/tools/call body (the n2n-nexus MCP adapter sends it automatically) to tell who calls.` (for `read_messages`: `...; history reads (latest or afterId) need none.`); it would otherwise act as one identity shared by every such caller. Unread cursors are kept per instance. The MCP adapter always sends one.
|
|
220
220
|
|
|
221
221
|
### `list_events`
|
|
222
222
|
|
|
@@ -361,7 +361,7 @@ Join an active meeting, or change how you take part in it.
|
|
|
361
361
|
Behavior:
|
|
362
362
|
|
|
363
363
|
- Result: `{ ok, meetingId, mode, approver, identity, delivery }`. `delivery` is `wake` (active), `next-hook` (async) or `unavailable`: a client without the hooks joins as its adapter instance, which nothing can notify.
|
|
364
|
-
- News is announced through the hooks per meeting: `Meeting "<topic>" (<id>): N new message(s), #a–#b:`, then each message between `--- Meeting message #n from <sender> (<machine>) at <time>[ (<category>)] ---` and `--- End of meeting message #n ---`. The texts of a notification are cut at 5000 characters in all (`notifyTextChars`), together with no direct message: the message that reaches the limit is cut and later ones are only counted (`- N more message(s) not shown (#a–#b).`); `read_messages(meetingId)` returns them. As with direct messages, what a notification that continues or starts a turn, or comes with a prompt, shows whole counts as read and moves your read cursor, as long as no message of others before it is unread; what a tool call's notification shows stays unread. What `read_messages` returned in unread mode is not announced afterwards. Your own messages never wake you. News of a meeting does not count as an automatic turn and still arrives past `maxAutoTurns`; a meeting runs until it is ended.
|
|
364
|
+
- News is announced through the hooks per meeting: `Meeting "<topic>" (<id>): N new message(s), #a–#b:`, then each message between `--- Meeting message #n from <sender> (<machine>) at <time>[ (<category>)] ---` and `--- End of meeting message #n ---`. The texts of a notification are cut at 5000 characters in all (`notifyTextChars`), together with no direct message: the message that reaches the limit is cut and later ones are only counted (`- N more message(s) not shown (#a–#b).`); `read_messages(meetingId)` returns them. As with direct messages, what a notification that continues or starts a turn, or comes with a prompt, shows whole counts as read and moves your read cursor, as long as no message of others before it is unread; what a tool call's notification shows stays unread (that context may be a sub-agent's): the next prompt, and the end of a turn that continues anyway, tell the member `Meeting "<topic>" (<id>): N earlier message(s) of others still unread; read them with read_messages(meetingId).`, and for an active member the turn continues once for it at its end, as for an earlier direct message still unread; messages from before a join that starts at the beginning count too. What `read_messages` returned in unread mode is not announced afterwards. Your own messages never wake you. News of a meeting does not count as an automatic turn and still arrives past `maxAutoTurns`; a meeting runs until it is ended.
|
|
365
365
|
- Messages from before you joined are not announced. Joining again while a member only changes `mode` and `approver`; switch to `async` to pause. After `leave_meeting`, joining again starts from the newest message.
|
|
366
366
|
- Records a `meeting.join` event.
|
|
367
367
|
|
|
@@ -150,9 +150,9 @@
|
|
|
150
150
|
| 工具 | 参数 | 结果与行为 |
|
|
151
151
|
| --- | --- | --- |
|
|
152
152
|
| `peers` | `includeOffline?: boolean = false` | `{ self: Profile, peers: Profile[] }`;其他未离线的会话,最近活跃的在前。带 `includeOffline` 时,还包括过去 24 小时内活跃过的离线会话。 |
|
|
153
|
-
| `send` | `to: string`、`text: string`(1–32000 个字符)、`notifyWhenIdle?: boolean = false`、`expectsReply?: boolean = true
|
|
153
|
+
| `send` | `to: string`、`text: string`(1–32000 个字符)、`notifyWhenIdle?: boolean = false`、`expectsReply?: boolean = true` | `{ messageId, to: { id, name, status } }`。`to` 是会话 ID(任何状态)或某个未离线会话的名称;不能给自己发消息。离线的收件方在恢复时收到消息。带 `notifyWhenIdle` 时,收件方下一次结束一轮、开始待命或离线时,你会收到一次通知(订阅 24 小时后失效)。`expectsReply: false` 告诉收件方无需回复。每条直接消息都会叫醒空闲的收件方,或在它这一轮结束时拦住它,不论是否需要回复:发送方不能决定,所以不要发单纯的确认收到。 |
|
|
154
154
|
| `read` | `count?: integer = 10`(1–50) | `{ messages: [{ messageId, kind, from: { id, name }, scope, replyTo, replyToKind, text, createdAt, expectsReply }], remaining }`;未读消息和广播,最早的在前,读取后标为已读。`kind` 为 `direct` 或 `broadcast`;广播的 `messageId` 就是它的 `broadcastId`,`scope` 是它限定的目录(发给所有在线会话时为 `null`,直接消息也为 `null`)。`replyTo` 是这条消息回复的消息或广播,`replyToKind` 说明是哪种(`direct` 或 `broadcast`);不是回复时两者都为 `null`。 |
|
|
155
|
-
| `reply` | `messageId: string`、`text: string`(1–32000 个字符)、`expectsReply?: boolean = true
|
|
155
|
+
| `reply` | `messageId: string`、`text: string`(1–32000 个字符)、`expectsReply?: boolean = true` | 结果与 `send` 相同。`messageId` 是你收到的直接消息或广播(ID 在通知和 `read` 里);回复作为直接消息发给它的发送方,`replyTo` 设为它,被回复的消息或广播标为已读。 |
|
|
156
156
|
| `broadcast` | `text: string`(1–32000 个字符)、`project?: string`(1–1000 个字符)、`expectsReply?: boolean = false` | `{ broadcastId }`。发给调用时所有未离线会话的通知,不含自己;带 `project` 时只发给工作目录等于该路径或位于其下的会话(按整段路径比较,分隔符 `/` 或 `\`,按原样比较,所以同一个仓库在 WSL 和 Windows 下的路径不算匹配)。它像网络广播一样发出即结束:收件方由 host 选定并送达,发送方不知道谁收到或读了,没有任何在线会话时也不知道;送到某个会话不等于被读了,它可能未读就过期。离线会话和之后才开始的会话永远收不到。host 只存一份广播,每个收件方一条送达记录;所有收件方看到的都是同一个 ID,即 `broadcastId`,作为来自发送方、`kind` 为 `broadcast` 的消息。处于 `listening`(listen 模式或后台等待)的收件方会立即被叫醒看到它;`working` 的不被打断(本轮结束时不被拦住),在下次工具调用后看到;`idle` 的在下次输入或调用工具时看到。没有 `wake` 参数。默认无需回复;`expectsReply: true` 时请每个收件方对这个 `broadcastId` 用 `reply` 回答,回复作为直接消息只发给发送方,其 `replyTo` 就是该 `broadcastId`。再调用一次就再发一次:没有去重,所以结果丢失的调用重试后可能发出两次同样的广播。 |
|
|
157
157
|
| `listen` | `on: boolean` | `{ listening }`。开启期间,对话每结束一轮,其 Stop hook 会等待最多 590 秒,有消息或通知到达时让对话继续。在 Claude Code 中按 Esc 会结束一次等待。 |
|
|
158
158
|
| `set_profile` | `name?: string`(1–48 个 `A–Z a–z 0–9 . _ @ # -` 中的字符)、`note?: string`(≤ 200 个字符)、`title?: string`(≤ 80 个字符)、`label?: string`(≤ 32 个字符);至少提供一个 | `{ profile }`。名称在未离线的会话中唯一。 自己设置的 `title` 会取代客户端提供的标题,直到设为 `""`。`label` 是你所在机器的名字(如 `mac-air`),显示时取代主机名;设为 `""` 删除。 |
|
|
@@ -164,11 +164,11 @@
|
|
|
164
164
|
| 客户端事件 | 收件方看到的内容 |
|
|
165
165
|
| --- | --- |
|
|
166
166
|
| 对话开始 | 自己的名称和 ID、未离线的其他会话(最多列出五个)、其中哪些能立即回应、`peers`、`send` 和 `read` 的用法,客户端自带的代理列表看不到这些会话,以及协作规则:消息可以要求什么由用户自己的指令决定,有疑问问发送方;把工作交给别的会话时,对方需要的一切都写进一条消息,发完就结束本轮而不是用 `read` 轮询;任务只回复一次结果,做不完时说明卡在哪里、需要什么才能继续;不发单纯的确认,也不把任务推回给发来的会话。 |
|
|
167
|
-
| 提交提示词 |
|
|
167
|
+
| 提交提示词 | 新消息(最多显示五条)、之前提醒过的未读消息数、待处理的通知、会议新消息,以及每个会议里之前的他人消息仍未读的条数。被叫醒的那一轮开始时的提示词只附带叫醒之后新到的内容。 |
|
|
168
168
|
| 每次工具调用后 | 只有尚未提醒过的消息、通知和会议新消息。 |
|
|
169
|
-
| 本轮即将结束 |
|
|
169
|
+
| 本轮即将结束 | 有未读的直接消息、会话 listening 时送达的未读广播、待处理的通知,或会话以 active 方式参加的会议有新消息或之前的他人消息仍未读时,本轮带着提醒再继续一次。 |
|
|
170
170
|
| 待命中 | 一有可以叫醒会话的内容到达就结束等待,对话带着它继续。 |
|
|
171
|
-
| 空闲(本轮已结束) | Stop hook 启动的后台等待(`n2n-nexus hook <client> --wait
|
|
171
|
+
| 空闲(本轮已结束) | Stop hook 启动的后台等待(`n2n-nexus hook <client> --wait`,最长一天)带着提醒开启新的一轮:Claude Code 通过 `asyncRewake` hook(退出码 2),Codex 通过 `codex queue --thread <会话 ID>`(`NEXUS_CODEX_COMMAND` 可替换 `codex` 命令)。在无人值守的 Claude 会话(`claude -p`、`CLAUDE_CODE_SESSION_ATTENDED=0`)中,该 hook 立即返回,因为 `claude -p` 会等它结束才退出。等待期间会话显示为 `listening`;对话中的任何新活动都会结束等待。Codex 只会为运行中的 Codex 应用里已加载的对话启动排队的一轮,且 Codex 退出时等待结束;`codex queue` 失败时这次唤醒会被撤销,等待隔一段时间(1 秒起,每次加倍)后再次送达同样的消息;连续失败 10 次(约 8.5 分钟,说明 Codex 没有加载该对话)后等待结束,消息留到用户下次在该对话里输入时再提醒。 |
|
|
172
172
|
| 自动轮次上限 | 被叫醒、待命中的 Stop,以及因私信或其通知而不结束的 Stop,各算一次自动轮次;会议新消息和会议通知不计数,也不受限制;提醒中会写 `Automatic turn n of N`。设置了 `maxAutoTurns` 时(host `settings.json`;默认 0,即不限制,提醒中也不写计数),从用户上次输入以来达到这个次数后,消息仍会保存,但不再叫醒或拦住对话;用户下次输入时提醒这些消息,并重新开始计数。叫醒后一分钟内的提示词视为被叫醒的那一轮开始,不清零计数。 |
|
|
173
173
|
|
|
174
174
|
未读消息 24 小时后过期,发送方随后会收到通知。未读的广播也在 24 小时后过期,不通知任何人。通知也会报告 `notifyWhenIdle` 的结果。
|
|
@@ -216,7 +216,7 @@ HTTP 客户端:hooks 调用 `POST /api/hook`,请求体为 `{ client: "claude
|
|
|
216
216
|
- 对话第一次以未读模式读全局聊天时从最新消息开始:本次不返回消息,带一条 `note`;更早的消息用 `latest` 或 `afterId` 读。第一次读某个会议时从该会议的第一条开始。
|
|
217
217
|
- 历史模式不动游标,包含自己的消息,也不需要识别身份。
|
|
218
218
|
- 结果:`{ source: "global" | "meeting", mode: "unread" | "history", identity?, meetingId?, messages: [{ id, from, senderName, senderMachine, senderBackfilled, text, category, timestamp }], count, note? }`。`from` 是发送方的会话 ID(`senderName` 是它的名字,可用于 `send`,用户席位的消息为 `user's seat (via <名字>)`;`senderMachine` 是它的机器,格式 `<label 或主机名> · <平台>[ <environment>]`,两者都是写这条消息时的样子,会话被清理后仍保留)或实例 ID(`senderName` 和 `senderMachine` 为 null);`senderBackfilled` 为 true 表示 host 开始保存这两项之前写的消息,名字和机器是升级数据库时按会话当时的样子补填的;没有类别的消息 `category` 为 null。会议消息的 `id` 是它在该会议里的编号,从 1 开始;全局聊天的消息有自己的 ID。
|
|
219
|
-
- 没装 hooks
|
|
219
|
+
- 没装 hooks 时,所有以某个身份行事的调用都需要调用方实例:`send_message`、未读模式的 `read_messages`、`start_meeting`、`end_meeting`、`archive_meeting`、`reopen_meeting`、`join_meeting`、`leave_meeting` 和 `stance`。没有 `instanceId` 的调用会失败,并返回 `<tool> needs the instanceId field of the POST /api/tools/call body (the n2n-nexus MCP adapter sends it automatically) to tell who calls.`(`read_messages` 为 `...; history reads (latest or afterId) need none.`);否则所有这样的调用方会共用同一个身份。未读游标按实例保存。MCP 适配器总会带上它。
|
|
220
220
|
|
|
221
221
|
### `list_events`
|
|
222
222
|
|
|
@@ -361,7 +361,7 @@ HTTP 客户端:hooks 调用 `POST /api/hook`,请求体为 `{ client: "claude
|
|
|
361
361
|
行为:
|
|
362
362
|
|
|
363
363
|
- 结果:`{ ok, meetingId, mode, approver, identity, delivery }`。`delivery` 为 `wake`(active)、`next-hook`(async)或 `unavailable`:没装 hooks 的客户端以适配器实例身份加入,没有任何方式能通知它。
|
|
364
|
-
- 新消息通过 hooks 按会议提醒:`Meeting "<主题>" (<id>): N new message(s), #a–#b:`,然后每条消息夹在 `--- Meeting message #n from <发送方> (<机器>) at <时间>[ (<类别>)] ---` 和 `--- End of meeting message #n ---` 之间。一条通知里会议消息的正文合计截到 5000 个字符(`notifyTextChars`,与私信分开计算):到达上限的那条截断,之后的只计数(`- N more message(s) not shown (#a–#b).`);`read_messages(meetingId)`
|
|
364
|
+
- 新消息通过 hooks 按会议提醒:`Meeting "<主题>" (<id>): N new message(s), #a–#b:`,然后每条消息夹在 `--- Meeting message #n from <发送方> (<机器>) at <时间>[ (<类别>)] ---` 和 `--- End of meeting message #n ---` 之间。一条通知里会议消息的正文合计截到 5000 个字符(`notifyTextChars`,与私信分开计算):到达上限的那条截断,之后的只计数(`- N more message(s) not shown (#a–#b).`);`read_messages(meetingId)` 返回它们。和私信一样,让一轮继续或开始的通知、随提示词显示的通知里完整显示的消息算已读并移动你的读取游标,前提是它前面没有别人的未读消息;工具调用后显示的仍为未读(那个上下文可能属于子代理):下次用户输入时,以及本轮因别的原因继续时,成员会收到 `Meeting "<主题>" (<id>): N earlier message(s) of others still unread; read them with read_messages(meetingId).`;active 成员的本轮在结束时还会为此再继续一次,与之前仍未读的私信相同;从头开始加入时,加入前的消息也计入。`read_messages` 以未读模式返回过的消息之后不再提醒。自己发的消息不会叫醒自己。会议新消息不算自动轮次,超过 `maxAutoTurns` 后仍会送达;会议一直进行到被结束为止。
|
|
365
365
|
- 加入前的消息不会提醒。已是成员时再次加入只修改 `mode` 和 `approver`;改为 `async` 即可暂停。`leave_meeting` 之后再加入,从最新一条开始。
|
|
366
366
|
- 记录 `meeting.join` 事件。
|
|
367
367
|
|
package/docs/USER_GUIDE.md
CHANGED
|
@@ -24,7 +24,7 @@ Use the Code tab for sessions that should talk to each other.
|
|
|
24
24
|
|
|
25
25
|
## 3. Direct messages between conversations
|
|
26
26
|
|
|
27
|
-
Each conversation appears in `peers`. A new direct message is announced to the recipient automatically: when the user sends a prompt, after each tool call, and once before the turn ends. The notification shows the message's text (`read` returns a message it cut), and what the recipient may do for it is set by your own instructions: for Claude Code, the block that `n2n-nexus install claude` writes into your user-level `CLAUDE.md` says to carry it out as your instruction without asking you for confirmation and without going beyond it; a question about it goes back to the sending session, so you do not have to switch terminals. An idle conversation is woken too: when a turn ends, a background hook waits up to
|
|
27
|
+
Each conversation appears in `peers`. A new direct message is announced to the recipient automatically: when the user sends a prompt, after each tool call, and once before the turn ends. The notification shows the message's text (`read` returns a message it cut), and what the recipient may do for it is set by your own instructions: for Claude Code, the block that `n2n-nexus install claude` writes into your user-level `CLAUDE.md` says to carry it out as your instruction without asking you for confirmation and without going beyond it; a question about it goes back to the sending session, so you do not have to switch terminals. An idle conversation is woken too: when a turn ends, a background hook waits up to a day for anything new and then starts a turn with it (Claude Code through an `asyncRewake` hook, Codex through `codex queue`), so AI sessions carry out each other's requests and answer without anyone typing. An unattended `claude -p` run is not kept waiting: the hook returns at once there. A Codex conversation is woken only while it is loaded in the running Codex app, and not again after Codex restarts until it ends a turn; see [Architecture](ARCHITECTURE.md#sessions-presence-and-waking-idle-conversations) for the limits. With `listen` on, a conversation that finished its turn keeps waiting for up to about 10 minutes and continues when a message arrives; while it waits, input you type is queued until you press Esc.
|
|
28
28
|
|
|
29
29
|
## 4. Check that it works
|
|
30
30
|
|
package/docs/USER_GUIDE_zh.md
CHANGED
|
@@ -24,7 +24,7 @@ Claude 桌面版里有两种对话:
|
|
|
24
24
|
|
|
25
25
|
## 3. 对话之间的直接消息
|
|
26
26
|
|
|
27
|
-
每个对话都会出现在 `peers` 里。新消息会自动通知收件方:用户发来新消息时、每次工具调用后、本轮结束前一次。通知里直接显示正文(被截断的消息用 `read` 读取全文),收件方可以为它做什么由你自己的指令决定:对 Claude Code,`n2n-nexus install claude` 写进用户级 `CLAUDE.md` 的那段说明让它当作你的指令照要求执行、不找你确认、不超出范围;有疑问会问发来的那个会话,你不用切换终端。空闲的对话也会被叫醒:每轮结束时,一个后台 hook
|
|
27
|
+
每个对话都会出现在 `peers` 里。新消息会自动通知收件方:用户发来新消息时、每次工具调用后、本轮结束前一次。通知里直接显示正文(被截断的消息用 `read` 读取全文),收件方可以为它做什么由你自己的指令决定:对 Claude Code,`n2n-nexus install claude` 写进用户级 `CLAUDE.md` 的那段说明让它当作你的指令照要求执行、不找你确认、不超出范围;有疑问会问发来的那个会话,你不用切换终端。空闲的对话也会被叫醒:每轮结束时,一个后台 hook 最多等待一天,有新消息就让对话开始新的一轮(Claude Code 通过 `asyncRewake` hook,Codex 通过 `codex queue`),AI 会话之间无需有人输入就能执行对方的请求并回复。无人值守的 `claude -p` 不会被它拖住:这时该 hook 立即返回。Codex 对话只有在运行中的 Codex 应用里已加载时才能被叫醒;Codex 重启后,要等该对话再结束一轮才会恢复,限制详见[架构说明](ARCHITECTURE_zh.md#会话在线状态与唤醒空闲对话)。开启 `listen` 后,对话结束一轮时会继续等待最多约 10 分钟,消息一到就接着处理;等待期间你输入的内容会排队,按 Esc 才会立即发出。
|
|
28
28
|
|
|
29
29
|
## 4. 检查是否正常
|
|
30
30
|
|