n2n-nexus 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.7.0] - 2026-10-20
6
+
7
+ ### Added
8
+
9
+ - Session discovery with `list_sessions` and `update_session`: each MCP connection has a distinct session ID, a display name, optional project association, an incoming-message policy and heartbeat-based presence. Connections sharing an instance ID have separate inboxes.
10
+ - Direct messages through `send_message(to, replyTo?, ttlMs?)`, plus `read_inbox`, `get_message` and `acknowledge_message`. Receipts distinguish accepted, fetched, acknowledged, replied, refused and expired messages. Unacknowledged fetched messages remain retrievable, and replies link back to the original message.
11
+
12
+ ### Changed
13
+
14
+ - MCP adapters register their connection identity and maintain it with heartbeats. Stable instance IDs continue to identify legacy shared-message cursors and meeting permissions.
15
+ - Updated compatible runtime and development dependencies, including Zod, ESLint, typescript-eslint, Vitest and Lefthook.
16
+
17
+ ### Fixed
18
+
19
+ - Strategy migration now records completion and preserves conflicting documents, preventing later user documents from being mistaken for legacy data and deleted on restart.
20
+ - Interrupted HTTP responses now reject the pending request instead of leaving connection attempts, tool calls or shutdown waiting indefinitely.
21
+ - Malformed Host headers and request URLs return HTTP 400 without terminating the daemon; existing browser and Host restrictions remain in effect.
22
+ - Concurrent attempts to rename one project to different destinations no longer both report success or record a rename that did not happen.
23
+ - MCP adapters can connect to IPv6 daemon endpoints without treating the address brackets as part of a DNS hostname.
24
+ - Task expiry preserves timestamp milliseconds so maintenance does not fail tasks before their TTL has elapsed.
25
+
26
+ ### Upgrade notes
27
+
28
+ - Upgrade the daemon first, then restart the MCP adapters on 0.7.0. New adapters require the daemon's session registration endpoints before executing tools. Existing meeting and global-message operations remain available with the upgraded daemon.
29
+ - Inboxes are per MCP connection. Host chats sharing one connection share its inbox, and restarting the adapter creates a new session rather than inheriting the previous inbox.
30
+ - Incoming messages require an explicit `read_inbox` call; this release does not automatically wake models. Online presence indicates a recent heartbeat, fetching does not prove processing, and acknowledgement confirms receipt rather than completion of work.
31
+
5
32
  ## [0.6.0] - 2026-10-16
6
33
 
7
34
  ### Added
package/README.md CHANGED
@@ -61,7 +61,7 @@ n2n-nexus gives AI assistants a shared coordination workspace. A long-running lo
61
61
  ```
62
62
 
63
63
  - **Daemon is the source of truth**: start it once and keep it running.
64
- - **MCP adapters are stateless**: each IDE starts an adapter through `npx`, then forwards tool calls to the daemon.
64
+ - **MCP adapters hold connection identity only**: each IDE starts an adapter through `npx`, then forwards tool calls to the daemon, which stores the session directory and inboxes.
65
65
  - **Cross-environment by design**: point `NEXUS_ENDPOINT` at the same daemon from different machines or shells. The daemon binds to localhost by default; use `--host 0.0.0.0` only on a trusted network.
66
66
 
67
67
  ## 🚀 Quick start
@@ -103,18 +103,21 @@ The adapter can start before the daemon. It loads the daemon tool list once the
103
103
 
104
104
  ## 🛠️ Toolset
105
105
 
106
- The daemon exposes 26 tools, grouped by purpose:
106
+ The daemon exposes 31 tools, grouped by purpose:
107
107
 
108
108
  | Group | Tools | Purpose |
109
109
  | --- | --- | --- |
110
110
  | **Topology** | `get_global_topology` | Read the project list or a detailed per-project subgraph. |
111
111
  | **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. |
112
112
  | **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. |
113
- | **Messaging** | `send_message`, `read_messages`, `list_events` | Post and read meeting and global messages; read the structured activity log. |
113
+ | **Messaging** | `send_message`, `read_messages`, `list_events` | Send direct, meeting or global messages; read shared messages and the activity log. |
114
+ | **Sessions and inboxes** | `list_sessions`, `update_session`, `read_inbox`, `get_message`, `acknowledge_message` | Discover connections, receive direct messages, and inspect explicit receipts and replies. |
114
115
  | **Meetings** | `list_meetings`, `start_meeting`, `end_meeting`, `archive_meeting`, `reopen_meeting` | List, open, close, archive, and reopen meeting sessions. |
115
116
  | **Tasks** | `create_task`, `get_task`, `list_tasks`, `cancel_task` | Create and track tasks that assistants manage themselves. |
116
117
  | **Maintenance** | `host_maintenance`, `host_delete_project` | Prune or clear the global chat stream; delete a project. |
117
118
 
119
+ Direct inboxes are per MCP connection and require explicit reads; messages do not automatically wake models. Chats sharing one connection share its inbox. Receipt states distinguish storage, fetching, acknowledgement and replies; acknowledgement does not mean the requested work is complete. See the [assistant workflow](docs/ASSISTANT_GUIDE.md#direct-conversations).
120
+
118
121
  See [Tools reference](./docs/TOOLS_REFERENCE.md) for parameter schemas and call examples.
119
122
 
120
123
  ## 💾 Data storage
@@ -35,12 +35,8 @@ export class NexusClient {
35
35
  const payload = body !== undefined ? JSON.stringify(body) : undefined;
36
36
  const transport = url.protocol === "https:" ? https : http;
37
37
  return new Promise((resolve, reject) => {
38
- const req = transport.request({
38
+ const req = transport.request(url, {
39
39
  method,
40
- protocol: url.protocol,
41
- hostname: url.hostname,
42
- port: url.port,
43
- path: `${url.pathname}${url.search}`,
44
40
  headers: {
45
41
  "Content-Type": "application/json",
46
42
  ...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {})
@@ -48,6 +44,11 @@ export class NexusClient {
48
44
  timeout: this.timeoutMs
49
45
  }, (res) => {
50
46
  let data = "";
47
+ const interrupted = () => reject(new NexusTransportError("Response from daemon ended before completion"));
48
+ res.on("aborted", interrupted);
49
+ res.on("error", (error) => reject(new NexusTransportError(error.message)));
50
+ res.on("close", () => { if (!res.complete)
51
+ interrupted(); });
51
52
  res.on("data", chunk => { data += chunk; });
52
53
  res.on("end", () => {
53
54
  let parsed;
@@ -78,8 +79,14 @@ export class NexusClient {
78
79
  const res = await this.request("GET", "/api/tools");
79
80
  return res.tools;
80
81
  }
81
- async callTool(name, args, instanceId) {
82
- const res = await this.request("POST", "/api/tools/call", { tool: name, args: args || {}, instanceId: instanceId || "unknown" });
82
+ async registerSession(session) {
83
+ await this.request("POST", "/api/sessions/register", session);
84
+ }
85
+ async disconnectSession(sessionId, instanceId) {
86
+ await this.request("POST", "/api/sessions/disconnect", { sessionId, instanceId });
87
+ }
88
+ async callTool(name, args, instanceId, sessionId) {
89
+ const res = await this.request("POST", "/api/tools/call", { tool: name, args: args || {}, instanceId: instanceId || "unknown", ...(sessionId ? { sessionId } : {}) });
83
90
  if (!res.ok)
84
91
  throw new NexusToolError(res.error || "Tool call failed", 200);
85
92
  return res.result;
@@ -4,13 +4,15 @@ import { URL } from "node:url";
4
4
  import { StorageManager } from "../storage/index.js";
5
5
  import { SqliteMeetingStore } from "../storage/sqlite-meeting.js";
6
6
  import { GlobalMessageStore } from "../storage/global-messages.js";
7
+ import { SessionStore } from "../storage/sessions.js";
8
+ import { DirectMessageStore } from "../storage/direct-messages.js";
7
9
  import { EventStore } from "../storage/events.js";
8
10
  import { runMigrations } from "../storage/migrations.js";
9
11
  import { createTask, updateTask, getTask, listTasks, cancelTask } from "../storage/tasks.js";
10
12
  import { MAX_BODY_BYTES } from "../constants.js";
11
13
  import { ADDRESS_LIMIT_FACTOR, RateLimiter, rateLimitFromEnv } from "./rate-limit.js";
12
14
  import { ResultContractError, finalizeResult } from "./tool-outputs.js";
13
- import { TOOL_DEFINITIONS, isToolName, parseToolArgs, taskUpdateSchema, ToolValidationError } from "./tools.js";
15
+ import { TOOL_DEFINITIONS, isToolName, parseToolArgs, taskUpdateSchema, sessionRegistrationSchema, sessionDisconnectSchema, ToolValidationError } from "./tools.js";
14
16
  import { z } from "zod";
15
17
  // ---------------------------------------------------------------------------
16
18
  // Storage bootstrap
@@ -77,7 +79,20 @@ async function toolGetProject(args) {
77
79
  ? { manifest, internalDocs: await StorageManager.getProjectDocs(args.projectId) }
78
80
  : { manifest };
79
81
  }
80
- async function toolSendMessage(args, instanceId) {
82
+ function requireSession(sessionId, instanceId) {
83
+ if (!sessionId)
84
+ throw new Error("A registered sessionId is required");
85
+ return SessionStore.require(sessionId, instanceId);
86
+ }
87
+ async function toolSendMessage(args, instanceId, sessionId) {
88
+ if (args.to !== undefined) {
89
+ const sender = requireSession(sessionId, instanceId);
90
+ const saved = DirectMessageStore.send(sender.sessionId, { ...args, to: args.to });
91
+ return {
92
+ ok: true, sentTo: "session", messageId: saved.messageId, status: saved.status,
93
+ to: saved.to, replyTo: saved.replyTo, message: `Direct message ${saved.status}.`
94
+ };
95
+ }
81
96
  const meetingId = args.meetingId ?? SqliteMeetingStore.getActiveMeetingId();
82
97
  if (meetingId) {
83
98
  const msg = {
@@ -208,8 +223,32 @@ async function toolHostDeleteProject(args, instanceId) {
208
223
  // ---------------------------------------------------------------------------
209
224
  // Tool dispatcher
210
225
  // ---------------------------------------------------------------------------
211
- async function dispatchTool(name, rawArgs, instanceId) {
226
+ async function dispatchTool(name, rawArgs, instanceId, sessionId) {
212
227
  switch (name) {
228
+ case "list_sessions": {
229
+ const args = parseToolArgs(name, rawArgs);
230
+ const self = sessionId ? requireSession(sessionId, instanceId) : null;
231
+ const sessions = SessionStore.list(args);
232
+ return { self, sessions, count: sessions.length };
233
+ }
234
+ case "update_session": {
235
+ const args = parseToolArgs(name, rawArgs);
236
+ return { session: SessionStore.update(requireSession(sessionId, instanceId).sessionId, args) };
237
+ }
238
+ case "read_inbox": {
239
+ const args = parseToolArgs(name, rawArgs);
240
+ const self = requireSession(sessionId, instanceId);
241
+ const messages = DirectMessageStore.readInbox(self.sessionId, args.count);
242
+ return { sessionId: self.sessionId, messages, count: messages.length };
243
+ }
244
+ case "get_message": {
245
+ const args = parseToolArgs(name, rawArgs);
246
+ return { message: DirectMessageStore.get(args.messageId, requireSession(sessionId, instanceId).sessionId) };
247
+ }
248
+ case "acknowledge_message": {
249
+ const args = parseToolArgs(name, rawArgs);
250
+ return { message: DirectMessageStore.acknowledge(args.messageId, requireSession(sessionId, instanceId).sessionId, args.outcome) };
251
+ }
213
252
  case "sync_project_assets": return toolSyncProjectAssets(parseToolArgs(name, rawArgs), instanceId);
214
253
  case "upload_project_asset": return toolUploadAsset(parseToolArgs(name, rawArgs), instanceId);
215
254
  case "update_project": return toolUpdateProject(parseToolArgs(name, rawArgs), instanceId);
@@ -217,7 +256,7 @@ async function dispatchTool(name, rawArgs, instanceId) {
217
256
  case "search_projects": return toolSearchProjects(parseToolArgs(name, rawArgs));
218
257
  case "get_global_topology": return toolGetGlobalTopology(parseToolArgs(name, rawArgs));
219
258
  case "get_project": return toolGetProject(parseToolArgs(name, rawArgs));
220
- case "send_message": return toolSendMessage(parseToolArgs(name, rawArgs), instanceId);
259
+ case "send_message": return toolSendMessage(parseToolArgs(name, rawArgs), instanceId, sessionId);
221
260
  case "read_messages": return toolReadMessages(parseToolArgs(name, rawArgs), instanceId);
222
261
  case "list_events": return toolListEvents(parseToolArgs(name, rawArgs));
223
262
  case "get_global_strategy":
@@ -271,9 +310,9 @@ function rateLimited(retryAfterSec, scope) {
271
310
  * Run a tool for an HTTP request. Validation failures and handler rejections
272
311
  * are the caller's fault and surface as 400, not as daemon errors.
273
312
  */
274
- async function callTool(name, args, instanceId) {
313
+ async function callTool(name, args, instanceId, sessionId) {
275
314
  try {
276
- return finalizeResult(name, await dispatchTool(name, args, instanceId));
315
+ return finalizeResult(name, await dispatchTool(name, args, instanceId, sessionId));
277
316
  }
278
317
  catch (e) {
279
318
  if (e instanceof HttpError)
@@ -291,6 +330,9 @@ const UNKNOWN_INSTANCE = "unknown";
291
330
  function actor(body) {
292
331
  return typeof body.instanceId === "string" && body.instanceId ? body.instanceId : UNKNOWN_INSTANCE;
293
332
  }
333
+ function sessionActor(body) {
334
+ return z.string().min(1).optional().parse(body.sessionId);
335
+ }
294
336
  /** Query-string parameters as tool arguments; `numeric` names are converted to numbers. */
295
337
  function queryArgs(url, numeric) {
296
338
  const args = {};
@@ -401,20 +443,27 @@ export async function createDaemonServer(options) {
401
443
  * Requests that name no instance ("unknown") are only limited per address:
402
444
  * pooling them in one shared bucket let any client exhaust it for all others.
403
445
  */
404
- const call = (name, args, instanceId) => {
446
+ const call = (name, args, instanceId, sessionId) => {
405
447
  if (instanceId !== UNKNOWN_INSTANCE) {
406
448
  const wait = instanceLimiter.take(`instance:${instanceId}`);
407
449
  if (wait)
408
450
  throw rateLimited(wait, `instance '${instanceId}'`);
409
451
  }
410
- return callTool(name, args, instanceId);
452
+ return callTool(name, args, instanceId, sessionId);
411
453
  };
412
454
  const server = http.createServer(async (req, res) => {
413
455
  const method = req.method || "GET";
414
- const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
415
- const path = url.pathname;
416
456
  const parseBody = (r) => parseRequestBody(r, maxBodyBytes);
417
457
  try {
458
+ let url;
459
+ try {
460
+ url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
461
+ }
462
+ catch {
463
+ req.resume();
464
+ throw new HttpError(400, "Invalid request URL or Host header");
465
+ }
466
+ const path = url.pathname;
418
467
  const guard = rejectBrowserRequest(req, allowedHosts);
419
468
  if (guard) {
420
469
  req.resume();
@@ -438,23 +487,44 @@ export async function createDaemonServer(options) {
438
487
  if (method === "GET" && path === "/api/tools") {
439
488
  return jsonResponse(res, 200, { tools: TOOL_DEFINITIONS });
440
489
  }
490
+ if (method === "POST" && path === "/api/sessions/register") {
491
+ const input = sessionRegistrationSchema.parse(await parseBody(req));
492
+ let session;
493
+ try {
494
+ session = SessionStore.register(input);
495
+ }
496
+ catch (e) {
497
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
498
+ }
499
+ return jsonResponse(res, 200, { ok: true, session });
500
+ }
501
+ if (method === "POST" && path === "/api/sessions/disconnect") {
502
+ const input = sessionDisconnectSchema.parse(await parseBody(req));
503
+ try {
504
+ SessionStore.disconnect(input.sessionId, input.instanceId);
505
+ }
506
+ catch (e) {
507
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
508
+ }
509
+ return jsonResponse(res, 200, { ok: true });
510
+ }
441
511
  // Tool call dispatcher ← core endpoint
442
512
  if (method === "POST" && path === "/api/tools/call") {
443
513
  const body = await parseBody(req);
444
514
  const toolName = body.tool;
445
515
  const args = (body.args || {});
446
- const instanceId = body.instanceId || UNKNOWN_INSTANCE;
516
+ const instanceId = actor(body);
447
517
  if (!toolName)
448
518
  return jsonResponse(res, 400, { ok: false, error: "tool is required" });
449
519
  if (!isToolName(toolName)) {
450
520
  return jsonResponse(res, 404, { ok: false, error: `Unknown tool: '${toolName}'` });
451
521
  }
452
- return jsonResponse(res, 200, { ok: true, result: await call(toolName, args, instanceId) });
522
+ return jsonResponse(res, 200, { ok: true, result: await call(toolName, args, instanceId, sessionActor(body)) });
453
523
  }
454
524
  // Messages
455
525
  if (method === "POST" && path === "/api/messages/send") {
456
526
  const body = await parseBody(req);
457
- return jsonResponse(res, 200, await call("send_message", body, actor(body)));
527
+ return jsonResponse(res, 200, await call("send_message", body, actor(body), sessionActor(body)));
458
528
  }
459
529
  if (method === "GET" && path === "/api/messages/unread") {
460
530
  const result = await call("read_messages", queryArgs(url, ["count"]), url.searchParams.get("instanceId") || UNKNOWN_INSTANCE);
@@ -12,6 +12,20 @@ import { TASK_STATUSES } from "../storage/tasks.js";
12
12
  * 2025-era clients require.
13
13
  */
14
14
  const ack = { ok: z.literal(true), message: z.string() };
15
+ const sessionSchema = z.object({
16
+ sessionId: z.string(), instanceId: z.string(), name: z.string(),
17
+ clientName: z.string(), workspace: z.string(), projectId: z.string().nullable(),
18
+ inbound: z.enum(["accept", "refuse"]), reception: z.literal("pull"),
19
+ online: z.boolean(), createdAt: z.string(), lastSeenAt: z.string()
20
+ });
21
+ const deliveryStatus = z.enum(["accepted", "fetched", "acknowledged", "replied", "refused", "expired"]);
22
+ const directMessageSchema = z.object({
23
+ messageId: z.string(), from: z.string(), to: z.string(), text: z.string(),
24
+ category: z.enum(MESSAGE_CATEGORIES).optional(), replyTo: z.string().nullable(),
25
+ status: deliveryStatus, createdAt: z.string(), expiresAt: z.string(),
26
+ fetchedAt: z.string().nullable(), acknowledgedAt: z.string().nullable(),
27
+ repliedAt: z.string().nullable(), reason: z.string().nullable()
28
+ });
15
29
  const messageSchema = z.object({
16
30
  id: z.number().int().optional(),
17
31
  from: z.string(),
@@ -80,11 +94,19 @@ export const TOOL_OUTPUT_SCHEMAS = {
80
94
  manifest: storedManifest,
81
95
  internalDocs: z.string().optional()
82
96
  }),
97
+ list_sessions: z.object({ self: sessionSchema.nullable(), sessions: z.array(sessionSchema), count: z.number().int() }),
98
+ update_session: z.object({ session: sessionSchema }),
99
+ read_inbox: z.object({ sessionId: z.string(), messages: z.array(directMessageSchema), count: z.number().int() }),
100
+ get_message: z.object({ message: directMessageSchema }),
101
+ acknowledge_message: z.object({ message: directMessageSchema }),
83
102
  send_message: z.object({
84
103
  ...ack,
85
- sentTo: z.enum(["meeting", "global"]),
104
+ sentTo: z.enum(["meeting", "global", "session"]),
86
105
  meetingId: z.string().optional(),
87
- messageId: z.number().int().optional()
106
+ messageId: z.union([z.number().int(), z.string()]).optional(),
107
+ status: deliveryStatus.optional(),
108
+ to: z.string().optional(),
109
+ replyTo: z.string().nullable().optional()
88
110
  }),
89
111
  read_messages: z.object({
90
112
  source: z.enum(["meeting", "global"]),
@@ -62,9 +62,28 @@ export const TOOL_SCHEMAS = {
62
62
  includeDocs: z.boolean().default(true).describe("Also return the internal docs (Markdown)")
63
63
  }),
64
64
  // Messaging
65
+ list_sessions: z.object({
66
+ onlineOnly: z.boolean().default(true),
67
+ projectId: projectIdSchema.optional(),
68
+ limit: positiveInt(500).default(100)
69
+ }),
70
+ update_session: z.object({
71
+ name: z.string().min(1).optional(),
72
+ projectId: projectIdSchema.nullable().optional(),
73
+ inbound: z.enum(["accept", "refuse"]).optional()
74
+ }),
75
+ read_inbox: z.object({ count: positiveInt(500).default(10) }),
76
+ get_message: z.object({ messageId: z.string().min(1) }),
77
+ acknowledge_message: z.object({
78
+ messageId: z.string().min(1),
79
+ outcome: z.enum(["acknowledged", "refused"]).default("acknowledged")
80
+ }),
65
81
  send_message: z.object({
66
82
  message: z.string().min(1),
67
83
  category: z.enum(MESSAGE_CATEGORIES).optional(),
84
+ to: z.string().min(1).optional().describe("Exact sessionId from list_sessions; mutually exclusive with meetingId"),
85
+ replyTo: z.string().min(1).optional().describe("Direct message being answered; requires to = original sender"),
86
+ ttlMs: positiveInt(Number.MAX_SAFE_INTEGER).optional().describe("Direct-message expiry in milliseconds; defaults to 24 hours"),
68
87
  meetingId: z.string().min(1).optional().describe("Target meeting. Defaults to the active meeting, or global chat when none is active.")
69
88
  }),
70
89
  read_messages: z.object({
@@ -123,7 +142,12 @@ const TOOL_DESCRIPTIONS = {
123
142
  search_projects: "Search project registry by name or description.",
124
143
  get_global_topology: "Default: project list + stats. With projectId: detailed subgraph.",
125
144
  get_project: "Read a project's full manifest and, by default, its internal docs.",
126
- send_message: "Post a message. Goes to meetingId if given, else to the active meeting, else to global chat.",
145
+ list_sessions: "Discover MCP connections and your own session. Online means a recent connection heartbeat, not model readiness. Delivery is pull-only.",
146
+ update_session: "Set your session name, project association or incoming-message policy. Refusing also rejects pending inbox messages.",
147
+ read_inbox: "Fetch your pending direct messages, oldest first. Unacknowledged messages remain retrievable; fetching does not confirm processing.",
148
+ get_message: "Read a direct message and its receipt status; only the sender and recipient may query it.",
149
+ acknowledge_message: "Explicitly acknowledge or refuse a fetched direct message. Acknowledgement confirms receipt, not completed work.",
150
+ send_message: "Send directly to an exact sessionId using to, optionally replying with replyTo. Otherwise post to meetingId, the active meeting, or global chat. Direct accepted means stored, not read or a woken model.",
127
151
  read_messages: "Read messages this instance has not read yet (cursor advances automatically). Global chat by default; pass meetingId for a meeting.",
128
152
  list_events: "Activity log: structured records of state changes (project.sync, project.rename, meeting.end, ...), newest first.",
129
153
  get_global_strategy: "Read the master strategy document (global/blueprint.md).",
@@ -153,6 +177,11 @@ const TOOL_TITLES = {
153
177
  get_global_topology: "Project Topology",
154
178
  get_project: "Read Project",
155
179
  send_message: "Send Message",
180
+ list_sessions: "List Sessions",
181
+ update_session: "Update My Session",
182
+ read_inbox: "Read My Inbox",
183
+ get_message: "Read Message Receipt",
184
+ acknowledge_message: "Acknowledge Message",
156
185
  read_messages: "Read Messages",
157
186
  list_events: "Activity Log",
158
187
  get_global_strategy: "Read Strategy",
@@ -188,7 +217,12 @@ const TOOL_ANNOTATIONS = {
188
217
  search_projects: readOnly,
189
218
  get_global_topology: readOnly,
190
219
  get_project: readOnly,
191
- send_message: write(false, false),
220
+ send_message: write(true, false), // a direct reply also updates the original message's receipt
221
+ list_sessions: readOnly,
222
+ update_session: write(true, true),
223
+ read_inbox: write(false, true),
224
+ get_message: readOnly,
225
+ acknowledge_message: write(true, true),
192
226
  read_messages: write(false, false), // only advances this instance's own unread cursor (bookkeeping, not shared data)
193
227
  list_events: readOnly,
194
228
  get_global_strategy: readOnly,
@@ -233,7 +267,7 @@ export const TOOL_NAMES = Object.keys(TOOL_SCHEMAS);
233
267
  */
234
268
  const PUBLISHED_OUTPUT_SCHEMAS = new Set([
235
269
  "search_projects", "get_global_topology", "get_project",
236
- "read_messages", "list_events",
270
+ "read_messages", "list_events", "list_sessions", "update_session", "read_inbox", "get_message", "acknowledge_message",
237
271
  "get_global_strategy", "list_global_docs", "get_global_doc",
238
272
  "list_meetings", "get_task", "list_tasks"
239
273
  ]);
@@ -259,8 +293,24 @@ export function parseToolArgs(name, args) {
259
293
  const result = TOOL_SCHEMAS[name].safeParse(args ?? {});
260
294
  if (!result.success)
261
295
  throw new ToolValidationError(name, z.prettifyError(result.error));
296
+ if (name === "send_message") {
297
+ const message = result.data;
298
+ if (message.to !== undefined && message.meetingId !== undefined) {
299
+ throw new ToolValidationError(name, "to and meetingId are mutually exclusive");
300
+ }
301
+ if (message.to === undefined && (message.replyTo !== undefined || message.ttlMs !== undefined)) {
302
+ throw new ToolValidationError(name, "replyTo and ttlMs require a direct recipient (to)");
303
+ }
304
+ }
262
305
  return result.data;
263
306
  }
307
+ export const sessionRegistrationSchema = z.object({
308
+ sessionId: z.string().min(1),
309
+ instanceId: z.string().min(1),
310
+ clientName: z.string().optional(),
311
+ workspace: z.string().optional()
312
+ });
313
+ export const sessionDisconnectSchema = sessionRegistrationSchema.pick({ sessionId: true, instanceId: true });
264
314
  /** Schema for POST /api/tasks/:id/update (not an MCP tool). */
265
315
  export const taskUpdateSchema = z.object({
266
316
  status: z.enum(TASK_STATUSES).optional(),
@@ -1,14 +1,16 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { CLIENT_INFO_META_KEY, ProtocolError, ProtocolErrorCode, Server, } from "@modelcontextprotocol/server";
2
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
4
  import { NexusClient, NexusToolError, NexusTransportError } from "../client/nexus-client.js";
4
5
  import { pkg } from "../config/index.js";
5
6
  import { resolveInstanceId } from "./instance-id.js";
6
7
  const RETRY_INTERVAL_MS = 3000;
8
+ const HEARTBEAT_INTERVAL_MS = 20_000;
7
9
  /** How long the first tools/list waits for the startup daemon fetch before answering with what it has. */
8
10
  const INITIAL_LIST_WAIT_MS = 1000;
9
11
  const SERVICE_NAME = "n2n-nexus";
10
12
  /**
11
- * Stateless MCP adapter in front of the daemon.
13
+ * MCP adapter in front of the daemon, with one addressable session per connection.
12
14
  *
13
15
  * Served through the v2 SDK's serveStdio entry, which answers both
14
16
  * protocol eras on one connection: 2026-07-28 clients (per-request `_meta`
@@ -24,6 +26,14 @@ export class NexusServer {
24
26
  /** True while a reconnect loop is active (waiting or attempting). */
25
27
  retrying = false;
26
28
  retryIntervalMs;
29
+ heartbeatIntervalMs;
30
+ sessionId = randomUUID();
31
+ instanceId;
32
+ workspace = process.cwd();
33
+ heartbeatTimer = null;
34
+ registration = null;
35
+ registrationAttempted = false;
36
+ shutdownPromise = null;
27
37
  connected = false;
28
38
  stopped = false;
29
39
  /**
@@ -37,19 +47,33 @@ export class NexusServer {
37
47
  constructor(options = {}) {
38
48
  const endpoint = process.env.NEXUS_ENDPOINT || "http://127.0.0.1:5688";
39
49
  this.retryIntervalMs = options.retryIntervalMs ?? RETRY_INTERVAL_MS;
50
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
40
51
  this.client = new NexusClient({ endpoint, timeoutMs: options.requestTimeoutMs ?? 5000 });
41
52
  }
42
53
  /** Builds the MCP server instance serveStdio pins for the connection. */
43
54
  buildServer() {
44
55
  const server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { tools: { listChanged: true } } });
45
- server.setRequestHandler("tools/list", async () => {
56
+ server.setRequestHandler("tools/list", async (_request, ctx) => {
57
+ this.identifyClient(ctx.mcpReq.envelope, server);
46
58
  // Give a local daemon a moment so the first list is complete; never block on a hung one.
47
59
  if (!this.connected && this.initialFetch) {
48
60
  await Promise.race([this.initialFetch, new Promise((r) => setTimeout(r, INITIAL_LIST_WAIT_MS))]);
49
61
  }
62
+ if (this.connected) {
63
+ try {
64
+ await this.registerSession();
65
+ }
66
+ catch (e) {
67
+ if (e instanceof NexusTransportError)
68
+ await this.daemonUnavailable();
69
+ else
70
+ throw e;
71
+ }
72
+ }
50
73
  return { tools: this.cachedTools };
51
74
  });
52
75
  server.setRequestHandler("tools/call", async (request, ctx) => {
76
+ this.identifyClient(ctx.mcpReq.envelope, server);
53
77
  if (!this.connected) {
54
78
  return {
55
79
  content: [{ type: "text", text: "Daemon is not ready yet. Please wait a moment and retry." }],
@@ -60,13 +84,13 @@ export class NexusServer {
60
84
  const unknownTool = () => new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
61
85
  if (!this.cachedTools.some((t) => t.name === request.params.name))
62
86
  throw unknownTool();
63
- // 2026-07-28 clients identify themselves per request; 2025-era clients did so in initialize.
64
- const envelope = ctx.mcpReq.envelope;
65
- this.clientName ??= envelope?.[CLIENT_INFO_META_KEY]?.name
66
- ?? server.getClientVersion()?.name;
67
- const instanceId = resolveInstanceId(process.env, process.argv, process.cwd(), this.clientName);
87
+ let callingTool = false;
68
88
  try {
69
- const result = await this.client.callTool(request.params.name, request.params.arguments, instanceId);
89
+ await this.registerSession();
90
+ if (this.stopped)
91
+ throw new NexusTransportError("MCP connection is closed");
92
+ callingTool = true;
93
+ const result = await this.client.callTool(request.params.name, request.params.arguments, this.instanceId, this.sessionId);
70
94
  // Every daemon result is a JSON object, so it goes out as structuredContent
71
95
  // (data tools also publish an outputSchema for it; write tools do not). The
72
96
  // JSON text keeps clients that only read content working (the spec's
@@ -79,7 +103,7 @@ export class NexusServer {
79
103
  catch (e) {
80
104
  const message = e instanceof Error ? e.message : String(e);
81
105
  // The daemon dropped the tool since the list was fetched: resync so tools/list stops offering it.
82
- if (e instanceof NexusToolError && e.status === 404) {
106
+ if (callingTool && e instanceof NexusToolError && e.status === 404) {
83
107
  await this.refreshTools();
84
108
  throw unknownTool();
85
109
  }
@@ -88,12 +112,7 @@ export class NexusServer {
88
112
  return { content: [{ type: "text", text: message }], isError: true };
89
113
  }
90
114
  // Daemon went away — drop the tool list and start the retry loop
91
- if (this.connected) {
92
- this.connected = false;
93
- this.cachedTools = [];
94
- await this.notifyToolsChanged();
95
- this.scheduleRetry();
96
- }
115
+ await this.daemonUnavailable();
97
116
  return {
98
117
  content: [{ type: "text", text: `Daemon unavailable: ${message}. Reconnecting...` }],
99
118
  isError: true
@@ -103,6 +122,52 @@ export class NexusServer {
103
122
  this.server = server;
104
123
  return server;
105
124
  }
125
+ identifyClient(meta, server) {
126
+ if (this.instanceId)
127
+ return;
128
+ const envelope = meta;
129
+ this.clientName = envelope?.[CLIENT_INFO_META_KEY]?.name
130
+ ?? server.getClientVersion()?.name;
131
+ this.instanceId = resolveInstanceId(process.env, process.argv, this.workspace, this.clientName);
132
+ }
133
+ async daemonUnavailable() {
134
+ if (this.connected) {
135
+ this.connected = false;
136
+ this.cachedTools = [];
137
+ await this.notifyToolsChanged();
138
+ }
139
+ this.scheduleRetry();
140
+ }
141
+ /** Coalesce requests so heartbeats and tool calls cannot stack registrations. */
142
+ registerSession() {
143
+ if (this.stopped || !this.instanceId)
144
+ return Promise.resolve();
145
+ if (this.registration)
146
+ return this.registration;
147
+ this.registrationAttempted = true;
148
+ this.registration = this.client.registerSession({
149
+ sessionId: this.sessionId, instanceId: this.instanceId,
150
+ clientName: this.clientName, workspace: this.workspace
151
+ }).finally(() => {
152
+ this.registration = null;
153
+ this.scheduleHeartbeat();
154
+ });
155
+ return this.registration;
156
+ }
157
+ scheduleHeartbeat() {
158
+ if (this.stopped || this.heartbeatTimer)
159
+ return;
160
+ this.heartbeatTimer = setTimeout(async () => {
161
+ this.heartbeatTimer = null;
162
+ try {
163
+ await this.registerSession();
164
+ }
165
+ catch (e) {
166
+ if (e instanceof NexusTransportError)
167
+ await this.daemonUnavailable();
168
+ }
169
+ }, this.heartbeatIntervalMs);
170
+ }
106
171
  /** Delivered unsolicited on 2025-era connections and via subscriptions/listen on 2026-07-28 ones. */
107
172
  async notifyToolsChanged() {
108
173
  await this.server?.sendToolListChanged().catch(() => { });
@@ -123,6 +188,12 @@ export class NexusServer {
123
188
  async tryConnect() {
124
189
  try {
125
190
  const tools = await this.client.fetchTools();
191
+ if (this.stopped)
192
+ return false;
193
+ if (this.instanceId)
194
+ await this.registerSession();
195
+ if (this.stopped)
196
+ return false;
126
197
  // JSON Schema produced by the daemon; the SDK models it as JSON values.
127
198
  this.cachedTools = tools;
128
199
  this.connected = true;
@@ -142,12 +213,22 @@ export class NexusServer {
142
213
  }
143
214
  }
144
215
  /** Stop reconnecting and close the MCP connection. Safe to call more than once. */
145
- async shutdown() {
146
- if (this.stopped)
147
- return;
216
+ shutdown() {
217
+ if (this.shutdownPromise)
218
+ return this.shutdownPromise;
148
219
  this.stopped = true;
149
220
  this.stopRetry();
150
- await this.handle?.close().catch(() => { });
221
+ if (this.heartbeatTimer)
222
+ clearTimeout(this.heartbeatTimer);
223
+ this.heartbeatTimer = null;
224
+ this.shutdownPromise = (async () => {
225
+ await this.registration?.catch(() => { });
226
+ if (this.registrationAttempted && this.instanceId) {
227
+ await this.client.disconnectSession(this.sessionId, this.instanceId).catch(() => { });
228
+ }
229
+ await this.handle?.close().catch(() => { });
230
+ })();
231
+ return this.shutdownPromise;
151
232
  }
152
233
  /**
153
234
  * Reconnect loop. Each attempt is scheduled only after the previous one
@@ -192,10 +273,16 @@ export class NexusServer {
192
273
  // Serve first so discover/initialize are answered even when the daemon hangs;
193
274
  // tools/list briefly waits for this fetch (2026-07-28 clients only hear
194
275
  // list_changed if they subscribe).
276
+ const transport = options.transport ?? new StdioServerTransport();
195
277
  this.handle = serveStdio(() => this.buildServer(), {
196
- transport: options.transport,
278
+ transport,
197
279
  onerror: (err) => console.error("[n2n-nexus] MCP transport error:", err.message)
198
280
  });
281
+ const onclose = transport.onclose;
282
+ transport.onclose = () => {
283
+ onclose?.();
284
+ void this.shutdown();
285
+ };
199
286
  this.initialFetch = this.tryConnect();
200
287
  const ok = await this.initialFetch;
201
288
  if (!ok && !this.stopped) {
@@ -0,0 +1,89 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SessionStore } from "./sessions.js";
3
+ import { getDatabase } from "./sqlite.js";
4
+ export const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60 * 1000;
5
+ function toMessage(row) {
6
+ return {
7
+ messageId: row.message_id, from: row.from_session_id, to: row.to_session_id,
8
+ text: row.text, category: row.category ?? undefined, replyTo: row.reply_to, status: row.status,
9
+ createdAt: row.created_at, expiresAt: row.expires_at, fetchedAt: row.fetched_at,
10
+ acknowledgedAt: row.acknowledged_at, repliedAt: row.replied_at, reason: row.reason
11
+ };
12
+ }
13
+ function expire(now) {
14
+ getDatabase().prepare("UPDATE direct_messages SET status = 'expired', reason = 'Message TTL elapsed' WHERE status IN ('accepted', 'fetched') AND expires_at <= ?")
15
+ .run(new Date(now).toISOString());
16
+ }
17
+ export class DirectMessageStore {
18
+ static send(fromSessionId, input, now = Date.now()) {
19
+ SessionStore.require(fromSessionId);
20
+ const ttlMs = input.ttlMs ?? DEFAULT_MESSAGE_TTL_MS;
21
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)
22
+ throw new Error("ttlMs must be a positive integer");
23
+ const db = getDatabase();
24
+ expire(now);
25
+ return db.transaction(() => {
26
+ const recipient = SessionStore.require(input.to);
27
+ if (input.replyTo) {
28
+ const original = this.get(input.replyTo, fromSessionId, now);
29
+ if (original.to !== fromSessionId || original.from !== input.to)
30
+ throw new Error("Reply must target the original sender");
31
+ if (!["fetched", "acknowledged", "replied"].includes(original.status))
32
+ throw new Error(`Cannot reply to a message with status ${original.status}`);
33
+ }
34
+ const messageId = randomUUID();
35
+ const timestamp = new Date(now).toISOString();
36
+ const status = recipient.inbound === "refuse" ? "refused" : "accepted";
37
+ db.prepare("INSERT INTO direct_messages (message_id, from_session_id, to_session_id, text, category, reply_to, status, created_at, expires_at, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
38
+ .run(messageId, fromSessionId, input.to, input.message, input.category ?? null, input.replyTo ?? null, status, timestamp, new Date(now + ttlMs).toISOString(), status === "refused" ? "Recipient refuses incoming messages" : null);
39
+ if (input.replyTo && status === "accepted") {
40
+ db.prepare("UPDATE direct_messages SET status = 'replied', replied_at = COALESCE(replied_at, ?) WHERE message_id = ?")
41
+ .run(timestamp, input.replyTo);
42
+ }
43
+ return this.get(messageId, fromSessionId, now);
44
+ })();
45
+ }
46
+ static readInbox(sessionId, count, now = Date.now()) {
47
+ SessionStore.require(sessionId);
48
+ const db = getDatabase();
49
+ return db.transaction(() => {
50
+ expire(now);
51
+ const rows = db.prepare("SELECT * FROM direct_messages WHERE to_session_id = ? AND status IN ('accepted', 'fetched') ORDER BY created_at ASC, rowid ASC LIMIT ?")
52
+ .all(sessionId, count);
53
+ const timestamp = new Date(now).toISOString();
54
+ const update = db.prepare("UPDATE direct_messages SET status = 'fetched', fetched_at = ? WHERE message_id = ? AND status = 'accepted'");
55
+ return rows.map(row => {
56
+ if (row.status === "accepted") {
57
+ update.run(timestamp, row.message_id);
58
+ row.status = "fetched";
59
+ row.fetched_at = timestamp;
60
+ }
61
+ return toMessage(row);
62
+ });
63
+ })();
64
+ }
65
+ static get(messageId, actorSessionId, now = Date.now()) {
66
+ expire(now);
67
+ const row = getDatabase().prepare("SELECT * FROM direct_messages WHERE message_id = ? AND (from_session_id = ? OR to_session_id = ?)")
68
+ .get(messageId, actorSessionId, actorSessionId);
69
+ if (!row)
70
+ throw new Error("Message not found for this session");
71
+ return toMessage(row);
72
+ }
73
+ static acknowledge(messageId, recipientSessionId, outcome, now = Date.now()) {
74
+ const db = getDatabase();
75
+ expire(now);
76
+ return db.transaction(() => {
77
+ const message = this.get(messageId, recipientSessionId, now);
78
+ if (message.to !== recipientSessionId)
79
+ throw new Error("Only the recipient can acknowledge a message");
80
+ if (message.status === outcome)
81
+ return message;
82
+ if (message.status !== "fetched")
83
+ throw new Error(`Cannot ${outcome === "acknowledged" ? "acknowledge" : "refuse"} a message with status ${message.status}`);
84
+ db.prepare("UPDATE direct_messages SET status = ?, acknowledged_at = ?, reason = ? WHERE message_id = ?")
85
+ .run(outcome, outcome === "acknowledged" ? new Date(now).toISOString() : null, outcome === "refused" ? "Recipient refused message" : null, messageId);
86
+ return this.get(messageId, recipientSessionId, now);
87
+ })();
88
+ }
89
+ }
@@ -84,12 +84,15 @@ export class StorageManager {
84
84
  }
85
85
  static renameProject(oldId, newId) {
86
86
  return this.write(async () => {
87
- await RegistryStorage.renameProject(oldId, newId);
87
+ if (!await RegistryStorage.getProjectManifest(oldId))
88
+ throw new Error(`Project '${oldId}' not found.`);
89
+ const renamed = await RegistryStorage.renameProject(oldId, newId);
90
+ if (renamed === 0)
91
+ throw new Error(`Project '${oldId}' could not be renamed.`);
88
92
  try {
89
93
  const result = await ProjectStorage.renameProject(oldId, newId);
90
- if (result === 0) {
91
- await RegistryStorage.renameProject(newId, oldId);
92
- }
94
+ if (result === 0)
95
+ throw new Error(`Project '${oldId}' could not be renamed.`);
93
96
  return result;
94
97
  }
95
98
  catch (error) {
@@ -9,8 +9,7 @@ import { MESSAGE_CATEGORIES } from "../types.js";
9
9
  import { FILE_ENCODING } from "../constants.js";
10
10
  /**
11
11
  * One-time upgrades of data written by earlier releases. Each step is
12
- * idempotent: it runs only while the legacy artifact is present and the
13
- * new location is still empty, then renames the artifact out of the way.
12
+ * idempotent: migrated artifacts are retired or completion is persisted.
14
13
  */
15
14
  /** 0.5.0 kept the global chat in global/discussion.json; import it into SQLite. */
16
15
  export async function importLegacyDiscussion() {
@@ -55,23 +54,31 @@ export async function importLegacyDiscussion() {
55
54
  }
56
55
  /** Title 0.5.0's update_global_strategy gave the doc it wrote; a user doc with another title is left alone. */
57
56
  export const LEGACY_STRATEGY_TITLE = "Global Collaboration Strategy";
57
+ const LEGACY_STRATEGY_MIGRATION = "migration:legacy_strategy";
58
58
  /**
59
59
  * 0.5.0's update_global_strategy saved the strategy as the global doc
60
60
  * "strategy"; it now lives in global/blueprint.md. Move it there unless the
61
- * blueprint has already been written.
61
+ * blueprint has already been written. Record completion even when there
62
+ * is nothing to move, so later user documents cannot become legacy data.
62
63
  */
63
64
  export async function importLegacyStrategy() {
64
- const docs = await DocStorage.listDocs();
65
- if (docs.strategy?.title !== LEGACY_STRATEGY_TITLE)
65
+ const db = getDatabase();
66
+ if (db.prepare("SELECT 1 FROM meeting_state WHERE key = ?").get(LEGACY_STRATEGY_MIGRATION))
66
67
  return false;
67
- const legacy = await DocStorage.getDoc("strategy");
68
- const current = await DocStorage.getBlueprint();
69
- if (legacy && (!current || current === BLUEPRINT_PLACEHOLDER)) {
70
- await DocStorage.saveBlueprint(legacy);
68
+ const docs = await DocStorage.listDocs();
69
+ let moved = false;
70
+ if (docs.strategy?.title === LEGACY_STRATEGY_TITLE) {
71
+ const legacy = await DocStorage.getDoc("strategy");
72
+ const current = await DocStorage.getBlueprint();
73
+ if (legacy && (!current || current === BLUEPRINT_PLACEHOLDER)) {
74
+ await DocStorage.saveBlueprint(legacy);
75
+ await DocStorage.deleteDoc("strategy");
76
+ moved = true;
77
+ console.error("[n2n-nexus] Moved legacy global doc 'strategy' into global/blueprint.md");
78
+ }
71
79
  }
72
- await DocStorage.deleteDoc("strategy");
73
- console.error("[n2n-nexus] Moved legacy global doc 'strategy' into global/blueprint.md");
74
- return true;
80
+ db.prepare("INSERT INTO meeting_state (key, value) VALUES (?, ?)").run(LEGACY_STRATEGY_MIGRATION, "complete");
81
+ return moved;
75
82
  }
76
83
  export async function runMigrations() {
77
84
  await importLegacyDiscussion();
@@ -0,0 +1,77 @@
1
+ import { getDatabase } from "./sqlite.js";
2
+ export const SESSION_LEASE_MS = 60_000;
3
+ function toSession(row, now) {
4
+ return {
5
+ sessionId: row.session_id, instanceId: row.instance_id, name: row.name,
6
+ clientName: row.client_name, workspace: row.workspace, projectId: row.project_id,
7
+ inbound: row.inbound, reception: "pull",
8
+ online: row.connected === 1 && now - Date.parse(row.last_seen_at) < SESSION_LEASE_MS,
9
+ createdAt: row.created_at, lastSeenAt: row.last_seen_at
10
+ };
11
+ }
12
+ export class SessionStore {
13
+ static register(input, now = Date.now()) {
14
+ const db = getDatabase();
15
+ return db.transaction(() => {
16
+ const existing = this.get(input.sessionId, now);
17
+ if (existing && existing.instanceId !== input.instanceId)
18
+ throw new Error("Session belongs to another instance");
19
+ const timestamp = new Date(now).toISOString();
20
+ if (existing) {
21
+ db.prepare("UPDATE sessions SET client_name = ?, workspace = ?, connected = 1, last_seen_at = ? WHERE session_id = ?")
22
+ .run(input.clientName ?? existing.clientName, input.workspace ?? existing.workspace, timestamp, input.sessionId);
23
+ }
24
+ else {
25
+ db.prepare("INSERT INTO sessions (session_id, instance_id, name, client_name, workspace, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
26
+ .run(input.sessionId, input.instanceId, `session-${input.sessionId.slice(0, 8)}`, input.clientName ?? "", input.workspace ?? "", timestamp, timestamp);
27
+ }
28
+ return this.get(input.sessionId, now);
29
+ })();
30
+ }
31
+ static get(sessionId, now = Date.now()) {
32
+ const row = getDatabase().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
33
+ return row ? toSession(row, now) : null;
34
+ }
35
+ static require(sessionId, instanceId) {
36
+ const session = this.get(sessionId);
37
+ if (!session)
38
+ throw new Error(`Unknown session: ${sessionId}`);
39
+ if (instanceId !== undefined && session.instanceId !== instanceId)
40
+ throw new Error("Session belongs to another instance");
41
+ return session;
42
+ }
43
+ static list(options = {}, now = Date.now()) {
44
+ const clauses = [];
45
+ const params = [];
46
+ if (options.projectId !== undefined) {
47
+ clauses.push("project_id = ?");
48
+ params.push(options.projectId);
49
+ }
50
+ if (options.onlineOnly) {
51
+ clauses.push("connected = 1 AND last_seen_at > ?");
52
+ params.push(new Date(now - SESSION_LEASE_MS).toISOString());
53
+ }
54
+ const rows = getDatabase().prepare(`SELECT * FROM sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY last_seen_at DESC, rowid DESC LIMIT ?`)
55
+ .all(...params, options.limit ?? 100);
56
+ return rows.map(row => toSession(row, now));
57
+ }
58
+ static update(sessionId, changes, now = Date.now()) {
59
+ const db = getDatabase();
60
+ return db.transaction(() => {
61
+ const existing = this.require(sessionId);
62
+ db.prepare("UPDATE sessions SET name = ?, project_id = ?, inbound = ? WHERE session_id = ?")
63
+ .run(changes.name ?? existing.name, changes.projectId === undefined ? existing.projectId : changes.projectId, changes.inbound ?? existing.inbound, sessionId);
64
+ if (changes.inbound === "refuse") {
65
+ db.prepare("UPDATE direct_messages SET status = 'expired', reason = 'Message TTL elapsed' WHERE to_session_id = ? AND status IN ('accepted', 'fetched') AND expires_at <= ?")
66
+ .run(sessionId, new Date(now).toISOString());
67
+ db.prepare("UPDATE direct_messages SET status = 'refused', reason = 'recipient_refused' WHERE to_session_id = ? AND status IN ('accepted', 'fetched')")
68
+ .run(sessionId);
69
+ }
70
+ return this.get(sessionId, now);
71
+ })();
72
+ }
73
+ static disconnect(sessionId, instanceId) {
74
+ this.require(sessionId, instanceId);
75
+ getDatabase().prepare("UPDATE sessions SET connected = 0 WHERE session_id = ?").run(sessionId);
76
+ }
77
+ }
@@ -98,6 +98,38 @@ CREATE TABLE IF NOT EXISTS read_cursors (
98
98
  updated_at TEXT,
99
99
  PRIMARY KEY (instance_id, context_type, context_id)
100
100
  );
101
+
102
+ -- Live MCP connections have separate identities even within one IDE/project.
103
+ CREATE TABLE IF NOT EXISTS sessions (
104
+ session_id TEXT PRIMARY KEY,
105
+ instance_id TEXT NOT NULL,
106
+ name TEXT NOT NULL,
107
+ client_name TEXT NOT NULL,
108
+ workspace TEXT NOT NULL,
109
+ project_id TEXT,
110
+ inbound TEXT NOT NULL CHECK(inbound IN ('accept', 'refuse')) DEFAULT 'accept',
111
+ connected INTEGER NOT NULL DEFAULT 1,
112
+ created_at TEXT NOT NULL,
113
+ last_seen_at TEXT NOT NULL
114
+ );
115
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
116
+
117
+ CREATE TABLE IF NOT EXISTS direct_messages (
118
+ message_id TEXT PRIMARY KEY,
119
+ from_session_id TEXT NOT NULL REFERENCES sessions(session_id),
120
+ to_session_id TEXT NOT NULL REFERENCES sessions(session_id),
121
+ text TEXT NOT NULL,
122
+ category TEXT CHECK(category IN ('MEETING_START', 'PROPOSAL', 'DECISION', 'UPDATE', 'CHAT')),
123
+ reply_to TEXT REFERENCES direct_messages(message_id),
124
+ status TEXT NOT NULL CHECK(status IN ('accepted', 'fetched', 'acknowledged', 'replied', 'refused', 'expired')),
125
+ created_at TEXT NOT NULL,
126
+ expires_at TEXT NOT NULL,
127
+ fetched_at TEXT,
128
+ acknowledged_at TEXT,
129
+ replied_at TEXT,
130
+ reason TEXT
131
+ );
132
+ CREATE INDEX IF NOT EXISTS idx_direct_messages_inbox ON direct_messages(to_session_id, status);
101
133
  `;
102
134
  /**
103
135
  * Get the database file path
@@ -118,7 +118,7 @@ export function expireTasks(now = Date.now()) {
118
118
  SET status = 'failed', error_message = 'TTL expired', updated_at = ?
119
119
  WHERE status IN ('pending', 'running')
120
120
  AND ttl IS NOT NULL
121
- AND strftime('%s', created_at) * 1000 + ttl < ?
121
+ AND ROUND(unixepoch(created_at, 'subsec') * 1000) + ttl < ?
122
122
  `).run(new Date(now).toISOString(), now).changes;
123
123
  }
124
124
  /**
@@ -20,16 +20,16 @@
20
20
  ▼ ▼ ▼
21
21
  MCP-A MCP-B MCP-C
22
22
  (Win) (WSL) (VM)
23
- Stateless protocol adapter, one per IDE
23
+ Lightweight protocol adapter, one per MCP connection
24
24
  ```
25
25
 
26
26
  ### Core Principles
27
27
 
28
28
  1. **Daemon is the single source of truth** — all reads/writes, business logic, and tool definitions live in the daemon.
29
- 2. **MCP is a stateless protocol adapter** — no tool definitions, no hardcoded names, no local data.
30
- 3. **Daemon decides tool capability** — MCP fetches `GET /api/tools` at startup; any daemon upgrade is immediately visible to all connected MCPs.
29
+ 2. **MCP holds connection identity only** — no hardcoded tool names or local persistent data; sessions and inboxes live in the daemon.
30
+ 3. **Daemon decides tool capability** — MCP fetches `GET /api/tools` at startup and reconnect. Session inboxes require both the session-aware adapter and daemon; legacy clients can continue using the existing shared-message APIs.
31
31
  4. **No local fallback** — if the daemon is unreachable, the MCP reports an error and retries every 3 s. No split-brain degradation. A tool call the daemon rejects (validation, not found, permission) is returned to the IDE as an error result (`isError: true`) and does not affect the connection; an unknown tool name is a JSON-RPC `-32602` error.
32
- 5. **Plain HTTP between MCP and daemon** — no SSE; AI is request-driven and doesn't need push.
32
+ 5. **Plain HTTP between MCP and daemon** — inboxes are pulled explicitly; heartbeats maintain presence but do not wake the model.
33
33
 
34
34
  ### MCP Startup Flow
35
35
 
@@ -51,6 +51,8 @@ Read NEXUS_ENDPOINT (default: http://127.0.0.1:5688)
51
51
 
52
52
  The adapter's instance ID (read cursors, meeting permissions) uses the first client name seen on the connection, from the request's `io.modelcontextprotocol/clientInfo` (2026-07-28) or from `initialize` (2025 era), unless `NEXUS_INSTANCE_ID` / `--id` pins it.
53
53
 
54
+ Each MCP connection also gets a random session ID, registered on its first tool request and refreshed every 20 seconds with a 60-second presence lease. Disconnect marks it offline; an adapter restart creates a new session, so pending messages for the old session are not transferred. Multiple host chats sharing a connection share its inbox. Project association is directory metadata, not an implicit target for project tools. Registration and disconnect use `POST /api/sessions/register` and `POST /api/sessions/disconnect`; direct tools receive session identity outside their model-supplied arguments. This remains a trusted local daemon, not an authentication boundary. See [tool reference](TOOLS_REFERENCE.md#session-directory-and-direct-inboxes).
55
+
54
56
  ---
55
57
 
56
58
  ## 💾 Data Persistence
@@ -145,7 +147,7 @@ npx n2n-nexus daemon --root ~/.n2n-nexus --port 5688
145
147
  ### Tool capability (MCP-facing)
146
148
  ```
147
149
  GET /api/tools Return full tool definition list (JSON Schema)
148
- POST /api/tools/call Execute tool call { tool, args, instanceId }
150
+ POST /api/tools/call Execute tool call { tool, args, instanceId, sessionId? }
149
151
  ```
150
152
 
151
153
  ### System
@@ -20,16 +20,16 @@
20
20
  ▼ ▼ ▼
21
21
  MCP-A MCP-B MCP-C
22
22
  (Win) (WSL) (VM)
23
- 无状态协议适配器,每个 IDE 一个
23
+ 轻量协议适配器,每条 MCP 连接一个
24
24
  ```
25
25
 
26
26
  ### 核心原则
27
27
 
28
28
  1. **Daemon 是唯一的真相来源** — 所有读写、业务逻辑和工具定义都在 daemon 侧。
29
- 2. **MCP 是无状态协议适配器** — 不含工具定义、不含 hardcode 工具名、不存本地数据。
30
- 3. **Daemon 决定工具能力** — MCP 启动时拉取 `GET /api/tools`;daemon 升级新工具后,所有已连接 MCP 立即获得新能力,无需更新。
29
+ 2. **MCP 只维护连接身份** — 不含 hardcode 工具名、不存本地持久化数据;会话目录和收件箱在 daemon。
30
+ 3. **Daemon 决定工具能力** — MCP 启动和重连时拉取 `GET /api/tools`。个人收件箱需要适配器和 daemon 都支持会话身份;旧客户端仍可使用原有共享消息接口。
31
31
  4. **无本地降级** — daemon 不可达时,MCP 每 3 秒重试并报告错误,不做本地 fallback(避免数据分裂)。daemon 拒绝的工具调用(校验失败、不存在、无权限)作为错误结果(`isError: true`)返回给 IDE,不影响连接;未知工具名返回 JSON-RPC `-32602` 错误。
32
- 5. **MCP ↔ Daemon 全部走普通 HTTP** — 不使用 SSE;AI 是请求驱动模型,不需要推送。
32
+ 5. **MCP ↔ Daemon 全部走普通 HTTP** — 收件箱由助手主动拉取;心跳只维护在线状态,不唤醒模型。
33
33
 
34
34
  ### MCP 启动流程
35
35
 
@@ -51,6 +51,8 @@ MCP-A MCP-B MCP-C
51
51
 
52
52
  适配器的实例 ID(未读游标、会议权限以它为键)取自该连接上第一次见到的客户端名,来源是请求 `_meta` 中的 `io.modelcontextprotocol/clientInfo`(2026-07-28)或 `initialize`(2025 代),`NEXUS_INSTANCE_ID` / `--id` 可固定。
53
53
 
54
+ 每条 MCP 连接还会生成随机会话 ID,在首次工具请求时注册,每 20 秒刷新一次心跳,在线租约为 60 秒。断连后标记离线;适配器重启会创建新会话,旧会话的待收消息不会转移。宿主多个聊天共用连接时也共用收件箱。项目关联仅用于目录筛选,不作为项目工具的隐式操作目标。注册和断连分别使用 `POST /api/sessions/register` 与 `POST /api/sessions/disconnect`,工具请求的会话身份放在模型参数之外。系统仍采用可信本地 daemon 边界,这不是身份认证机制。精确契约见[工具参考](TOOLS_REFERENCE.md#session-directory-and-direct-inboxes)。
55
+
54
56
  ---
55
57
 
56
58
  ## 💾 数据持久化
@@ -113,7 +115,7 @@ n2n-nexus
113
115
  │
114
116
  ├─ n2n-nexus daemon 用户手动启动一次,持续运行,持有所有数据。
115
117
  │
116
- └─ n2n-nexus mcp IDE 通过 npx 自动启动,无状态代理。
118
+ └─ n2n-nexus mcp IDE 通过 npx 自动启动,维护连接身份。
117
119
  ```
118
120
 
119
121
  **Daemon**(用户手动启动):
@@ -145,7 +147,7 @@ npx n2n-nexus daemon --root ~/.n2n-nexus --port 5688
145
147
  ### 工具能力(MCP 对接)
146
148
  ```
147
149
  GET /api/tools 返回完整工具定义列表(JSON Schema)
148
- POST /api/tools/call 执行工具调用 { tool, args, instanceId }
150
+ POST /api/tools/call 执行工具调用 { tool, args, instanceId, sessionId? }
149
151
  ```
150
152
 
151
153
  ### 系统
@@ -22,7 +22,7 @@ read_messages(count) → fetch messages you have not yet read (increm
22
22
 
23
23
  ### 2. Project IDs
24
24
 
25
- There is no session to set up: every project tool takes the `projectId` it acts on.
25
+ Project operations need no implicit project context: every project tool takes the `projectId` it acts on.
26
26
  Project IDs must follow `[prefix]_[name]` format, for example: `web_my-site`, `api_user-auth`, `mcp_nexus`.
27
27
 
28
28
  ### 3. Write project data
@@ -36,6 +36,15 @@ upload_project_asset(projectId, fileName, base64Content) → upload a binary fil
36
36
 
37
37
  All write tools run synchronously and return their result directly.
38
38
 
39
+ ### Direct conversations
40
+
41
+ 1. Call `list_sessions` to find the exact destination `sessionId`; use `update_session` to set your name and project association.
42
+ 2. Send `send_message({ to: sessionId, message: "Please review this change" })`. Inspect `status`: accepted means stored, refused means rejected.
43
+ 3. The recipient calls `read_inbox`, then `acknowledge_message({ messageId })` to confirm receipt or passes `outcome: "refused"` to decline.
44
+ 4. Reply with `send_message({ to: original.from, replyTo: original.messageId, message: "Review result..." })`. The sender can query `get_message({ messageId })` for the original receipt.
45
+
46
+ Unconfirmed fetched messages can appear again; use their IDs to recognize retries. Acknowledgement does not mean work is complete. The inbox is pull-only: there is no automatic model wakeup. Connection restarts create new session IDs; discover the destination again. See [receipt and session contracts](TOOLS_REFERENCE.md#session-directory-and-direct-inboxes).
47
+
39
48
  ### 4. Messages and collaboration
40
49
 
41
50
  ```
package/docs/README_zh.md CHANGED
@@ -31,7 +31,7 @@ n2n-nexus 是一个开源的 Model Context Protocol (MCP) 协同服务器,适
31
31
  - AI 助手项目注册表
32
32
  - 编码 agent 会议和任务协同
33
33
 
34
- n2n-nexus 面向这些场景设计:共享 daemon 状态、无状态 MCP 适配器、项目感知协作、本地存储和跨环境 IDE 工作流。
34
+ n2n-nexus 面向这些场景设计:共享 daemon 状态、轻量 MCP 适配器、项目感知协作、本地存储和跨环境 IDE 工作流。
35
35
 
36
36
  ## n2n-nexus 是什么?
37
37
 
@@ -66,11 +66,11 @@ n2n-nexus 为 AI 助手提供共享协作空间。不同助手不再只依赖自
66
66
  ▼ ▼ ▼
67
67
  MCP-A MCP-B MCP-C
68
68
  (Win) (WSL) (SSH/VM)
69
- 每个 IDE 一个无状态适配器
69
+ 每条 MCP 连接一个轻量适配器
70
70
  ```
71
71
 
72
72
  - **Daemon 是唯一数据源**:启动一次并保持运行。
73
- - **MCP 适配器无状态**:每个 IDE 通过 `npx` 启动适配器,然后把工具调用转发给 daemon。
73
+ - **MCP 适配器只维护连接身份**:每个 IDE 通过 `npx` 启动适配器,然后把工具调用转发给 daemon;会话目录和收件箱保存在 daemon。
74
74
  - **跨环境协作**:通过 `NEXUS_ENDPOINT` 让不同机器或 shell 指向同一个 daemon。daemon 默认只监听本机;只有在可信网络中才使用 `--host 0.0.0.0`。
75
75
 
76
76
  ## 快速开始
@@ -124,7 +124,10 @@ npx n2n-nexus daemon --port 5688
124
124
 
125
125
  ### 消息和协作
126
126
 
127
- - `send_message`:发送会议消息或全局消息。
127
+ - `send_message`:向精确会话 ID 发送定向消息,也保留会议消息和全局消息。
128
+ - `list_sessions` / `update_session`:发现连接,设置自己的名称、项目关联和接收策略。
129
+ - `read_inbox` / `acknowledge_message`:取回个人消息并明确确认或拒绝。
130
+ - `get_message`:查询自己发送或接收的消息状态和回复关联。
128
131
  - `read_messages`:增量读取未读消息(全局或指定会议)。
129
132
  - `list_events`:读取结构化活动日志(项目同步、重命名、会议结束等)。
130
133
  - `get_global_strategy`:读取主策略文档。
@@ -133,6 +136,8 @@ npx n2n-nexus daemon --port 5688
133
136
  - `get_global_doc`:读取共享文档。
134
137
  - `sync_global_doc`:创建或更新共享文档。
135
138
 
139
+ 每条 MCP 连接有独立会话 ID;多个聊天若共用连接,仍视为一个会话。在线仅表示近期心跳,消息采用主动拉取,不自动唤醒模型。已存储、已取回、已确认和已回复是不同状态,确认收件不等于任务完成。使用流程见[助手指南](ASSISTANT_GUIDE_zh.md),精确契约见[工具参考](TOOLS_REFERENCE.md#session-directory-and-direct-inboxes)。
140
+
136
141
  ### 会议管理
137
142
 
138
143
  - `list_meetings`:列出会议(状态、参与者、消息与决策数、摘要)。
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # n2n-nexus
2
2
 
3
- n2n-nexus is a local-first Model Context Protocol (MCP) coordination hub from N2NS Lab for multi-AI assistant collaboration. It uses a long-running local daemon as the source of truth and stateless MCP adapters for IDEs and AI coding assistants.
3
+ n2n-nexus is a local-first Model Context Protocol (MCP) coordination hub from N2NS Lab for multi-AI assistant collaboration. It uses a long-running local daemon as the source of truth and lightweight MCP adapters for IDEs and AI coding assistants. Each MCP connection has a discoverable session identity and a pull-based direct inbox with explicit receipts and reply links; messages do not automatically wake models.
4
4
 
5
5
  ## Search intent coverage
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-nexus",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local-first MCP coordination hub from N2NS Lab for multi-AI assistant collaboration.",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -84,17 +84,17 @@
84
84
  "dependencies": {
85
85
  "@modelcontextprotocol/server": "^2.3.1",
86
86
  "better-sqlite3": "^13.0.3",
87
- "zod": "^4.6.6"
87
+ "zod": "^4.6.7"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@eslint/js": "^10.0.1",
91
91
  "@modelcontextprotocol/client": "^2.3.1",
92
92
  "@types/better-sqlite3": "^9.6.0",
93
93
  "@types/node": "^26.6.5",
94
- "eslint": "^10.12.0",
95
- "lefthook": "^2.2.1",
94
+ "eslint": "^10.13.0",
95
+ "lefthook": "^2.2.2",
96
96
  "typescript": "^6.0.3",
97
- "typescript-eslint": "^8.71.1",
98
- "vitest": "^5.0.3"
97
+ "typescript-eslint": "^8.72.0",
98
+ "vitest": "^5.0.5"
99
99
  }
100
100
  }