@openvole/volenet-mcp 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,25 +1,44 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
7
-
8
- // src/cli.ts
9
- import * as path4 from "path";
10
- import { loadKeyPair } from "@openvole/volenet";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
11
16
 
12
17
  // src/config.ts
18
+ var config_exports = {};
19
+ __export(config_exports, {
20
+ defaultDir: () => defaultDir,
21
+ defaultName: () => defaultName,
22
+ loadStored: () => loadStored,
23
+ resolveSettings: () => resolveSettings,
24
+ saveStored: () => saveStored,
25
+ sessionKey: () => sessionKey
26
+ });
27
+ import * as crypto from "crypto";
13
28
  import * as fs from "fs/promises";
14
29
  import * as os from "os";
15
30
  import * as path from "path";
31
+ function sessionKey(cwd = process.cwd()) {
32
+ const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 8);
33
+ const base = (cwd.split("/").filter(Boolean).pop() ?? "session").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 40);
34
+ return `${base}-${hash}`;
35
+ }
16
36
  function defaultDir() {
17
37
  return process.env.VOLENET_MCP_DIR?.trim() || path.join(os.homedir(), ".openvole", "volenet-mcp");
18
38
  }
19
39
  function defaultName() {
20
40
  return `claude-${os.hostname().split(".")[0].toLowerCase()}`;
21
41
  }
22
- var file = (dir) => path.join(dir, "config.json");
23
42
  async function loadStored(dir) {
24
43
  try {
25
44
  const raw = JSON.parse(await fs.readFile(file(dir), "utf-8"));
@@ -46,92 +65,645 @@ async function resolveSettings() {
46
65
  name: process.env.VOLENET_MCP_NAME?.trim() || stored.name || defaultName(),
47
66
  hub: process.env.VOLENET_MCP_HUB?.trim() || stored.hub || void 0,
48
67
  dir,
49
- port: (Number.isFinite(envPort) && envPort > 0 ? envPort : stored.port) || 9750
68
+ port: (Number.isFinite(envPort) && envPort > 0 ? envPort : stored.port) || 9750,
69
+ session: process.env.VOLENET_MCP_SESSION?.trim() || sessionKey()
50
70
  };
51
71
  }
72
+ var file;
73
+ var init_config = __esm({
74
+ "src/config.ts"() {
75
+ "use strict";
76
+ file = (dir) => path.join(dir, "config.json");
77
+ }
78
+ });
52
79
 
53
80
  // src/inbox.ts
54
81
  import * as fs2 from "fs/promises";
55
82
  import * as path2 from "path";
56
- var MAX_MESSAGES = 2e3;
57
- var Inbox = class {
58
- constructor(file2) {
59
- this.file = file2;
60
- }
61
- file;
62
- messages = [];
63
- /** Per peer, the timestamp up to which the session has been shown its messages. */
64
- readAt = /* @__PURE__ */ new Map();
65
- writing = Promise.resolve();
66
- async load() {
83
+ async function readLog(file2) {
84
+ let body;
85
+ try {
86
+ body = await fs2.readFile(file2, "utf-8");
87
+ } catch {
88
+ return [];
89
+ }
90
+ const out = [];
91
+ for (const line of body.split("\n")) {
92
+ if (!line.trim()) continue;
67
93
  try {
68
- const raw = JSON.parse(await fs2.readFile(this.file, "utf-8"));
69
- this.messages = (raw.messages ?? []).filter(
70
- (m) => m && typeof m.peerId === "string" && typeof m.text === "string"
94
+ const m = JSON.parse(line);
95
+ if (m && typeof m.peerId === "string" && typeof m.text === "string") out.push(m);
96
+ } catch {
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ var MAX_MESSAGES, Inbox;
102
+ var init_inbox = __esm({
103
+ "src/inbox.ts"() {
104
+ "use strict";
105
+ MAX_MESSAGES = 2e3;
106
+ Inbox = class {
107
+ /**
108
+ * @param dir where the shared log and the cursors live
109
+ * @param session which read state is ours. Sessions in different projects are different
110
+ * readers; the same project reopened is the same reader, so restarting does
111
+ * not replay everything already seen.
112
+ */
113
+ constructor(dir, session = "default") {
114
+ this.dir = dir;
115
+ this.session = session;
116
+ }
117
+ dir;
118
+ session;
119
+ messages = [];
120
+ /** Per peer, the timestamp up to which *this* session has been shown its messages. */
121
+ readAt = /* @__PURE__ */ new Map();
122
+ writing = Promise.resolve();
123
+ get log() {
124
+ return path2.join(this.dir, "messages.jsonl");
125
+ }
126
+ get cursor() {
127
+ return path2.join(this.dir, "cursors", `${this.session}.json`);
128
+ }
129
+ async load() {
130
+ await this.adoptLegacy();
131
+ this.messages = await readLog(this.log);
132
+ try {
133
+ const raw = JSON.parse(await fs2.readFile(this.cursor, "utf-8"));
134
+ this.readAt = new Map(Object.entries(raw ?? {}));
135
+ } catch {
136
+ this.readAt = /* @__PURE__ */ new Map();
137
+ }
138
+ }
139
+ /** Re-read what other sessions have appended since we loaded. */
140
+ async refresh() {
141
+ this.messages = await readLog(this.log);
142
+ }
143
+ /** Record a message. Returns false when this id was already recorded. */
144
+ async add(m) {
145
+ await this.refresh();
146
+ if (this.messages.some((x) => x.id === m.id)) return false;
147
+ this.messages.push(m);
148
+ await this.append(m);
149
+ return true;
150
+ }
151
+ /** Everything with one peer, oldest first. */
152
+ history(peerId, limit = 50) {
153
+ return this.messages.filter((m) => m.peerId === peerId).slice(-limit);
154
+ }
155
+ /** Inbound messages this session has not been shown yet, oldest first. */
156
+ unread() {
157
+ return this.messages.filter((m) => m.dir === "in" && m.ts > (this.readAt.get(m.peerId) ?? 0));
158
+ }
159
+ /** Mark everything currently unread as seen — for this session, and nobody else. */
160
+ async markRead() {
161
+ for (const m of this.unread()) {
162
+ const at = this.readAt.get(m.peerId) ?? 0;
163
+ if (m.ts > at) this.readAt.set(m.peerId, m.ts);
164
+ }
165
+ await this.persistCursor();
166
+ }
167
+ /** Every peer we have said anything to or heard anything from, most recent first. */
168
+ peers() {
169
+ const by = /* @__PURE__ */ new Map();
170
+ for (const m of this.messages) {
171
+ const e = by.get(m.peerId) ?? { peerId: m.peerId, peerName: m.peerName, last: 0, unread: 0 };
172
+ if (m.peerName) e.peerName = m.peerName;
173
+ e.last = Math.max(e.last, m.ts);
174
+ if (m.dir === "in" && m.ts > (this.readAt.get(m.peerId) ?? 0)) e.unread++;
175
+ by.set(m.peerId, e);
176
+ }
177
+ return [...by.values()].sort((a, b) => b.last - a.last);
178
+ }
179
+ get size() {
180
+ return this.messages.length;
181
+ }
182
+ /** One line, one write — an append no other session can lose. */
183
+ append(m) {
184
+ this.writing = this.writing.then(async () => {
185
+ await fs2.mkdir(this.dir, { recursive: true });
186
+ await fs2.appendFile(this.log, `${JSON.stringify(m)}
187
+ `, "utf-8");
188
+ if (this.messages.length > MAX_MESSAGES) await this.compact();
189
+ });
190
+ return this.writing;
191
+ }
192
+ /** Rewrite the log with the newest MAX_MESSAGES. Rare, and atomic via rename. */
193
+ async compact() {
194
+ const keep = this.messages.slice(-MAX_MESSAGES);
195
+ const tmp = `${this.log}.${process.pid}.tmp`;
196
+ await fs2.writeFile(tmp, keep.map((m) => `${JSON.stringify(m)}
197
+ `).join(""), "utf-8");
198
+ await fs2.rename(tmp, this.log);
199
+ this.messages = keep;
200
+ }
201
+ async persistCursor() {
202
+ await fs2.mkdir(path2.dirname(this.cursor), { recursive: true });
203
+ const tmp = `${this.cursor}.tmp`;
204
+ await fs2.writeFile(tmp, JSON.stringify(Object.fromEntries(this.readAt), null, 2), "utf-8");
205
+ await fs2.rename(tmp, this.cursor);
206
+ }
207
+ /**
208
+ * Carry over messages written before the log existed.
209
+ *
210
+ * Earlier versions kept one `inbox.json` holding both the messages and a single read state. The
211
+ * messages are still someone's; dropping them on upgrade would lose real conversations. The old
212
+ * read state is deliberately *not* carried over — it was one cursor for every session, so honouring
213
+ * it would mark messages seen for sessions that never saw them. Unread is the safe direction.
214
+ */
215
+ async adoptLegacy() {
216
+ const legacy = path2.join(this.dir, "inbox.json");
217
+ try {
218
+ await fs2.access(this.log);
219
+ return;
220
+ } catch {
221
+ }
222
+ let raw;
223
+ try {
224
+ raw = JSON.parse(await fs2.readFile(legacy, "utf-8"));
225
+ } catch {
226
+ return;
227
+ }
228
+ const messages = (raw.messages ?? []).filter(
229
+ (m) => m && typeof m.peerId === "string" && typeof m.text === "string"
230
+ );
231
+ if (messages.length === 0) return;
232
+ await fs2.mkdir(this.dir, { recursive: true });
233
+ await fs2.writeFile(this.log, messages.map((m) => `${JSON.stringify(m)}
234
+ `).join(""), "utf-8");
235
+ await fs2.rename(legacy, `${legacy}.migrated`);
236
+ }
237
+ };
238
+ }
239
+ });
240
+
241
+ // src/net-api.ts
242
+ function localNet(m) {
243
+ return {
244
+ async identity() {
245
+ const k = m.getKeyPair();
246
+ return k ? { instanceId: k.instanceId, publicKeyString: k.publicKeyString } : null;
247
+ },
248
+ async instances() {
249
+ const live = new Set(
250
+ (m.getTransport()?.getPeers() ?? []).filter((p) => p.connected).map((p) => p.peerId)
71
251
  );
72
- this.readAt = new Map(Object.entries(raw.readAt ?? {}));
252
+ return m.getInstances().map((i) => ({ id: i.id, name: i.name, connected: live.has(i.id) }));
253
+ },
254
+ async relayMembers() {
255
+ return m.getRelayMembers().map((r) => ({
256
+ id: r.id,
257
+ name: r.name,
258
+ viaHubName: r.viaHubName,
259
+ connected: r.connected,
260
+ accepted: r.accepted,
261
+ incoming: r.incoming,
262
+ awaiting: r.awaiting
263
+ }));
264
+ },
265
+ sendChat: (to, text) => m.sendChat(to, text),
266
+ async askBrain(to, input, fromName, timeoutMs) {
267
+ const mgr = m.getRemoteTaskManager();
268
+ if (!mgr) return { status: "failed", error: "remote task manager not available" };
269
+ const r = await mgr.delegateTask(to, { taskId: "", input, fromName }, timeoutMs);
270
+ return { status: r.status, result: r.result, error: r.error };
271
+ },
272
+ joinHub: (url) => m.initiateJoin(url),
273
+ addPeer: (url) => m.addPeer(url),
274
+ async forgetPeer(url) {
275
+ return m.forgetPeer(url);
276
+ },
277
+ probePair: (url) => m.probePair(url),
278
+ initiatePair: (url, publicKey, note, wants) => m.initiatePair(url, publicKey, note, wants),
279
+ requestRelayConnect: (ref, note) => m.requestRelayConnect(ref, note),
280
+ approveRelayConnect: (ref) => m.approveRelayConnect(ref),
281
+ denyRelayConnect: (ref) => m.denyRelayConnect(ref),
282
+ async listPairRequests() {
283
+ return m.listPairRequests().map((r) => ({
284
+ id: r.id,
285
+ name: r.name,
286
+ note: r.note,
287
+ wants: r.wants
288
+ }));
289
+ },
290
+ async rooms() {
291
+ return m.getRooms().map((r) => ({
292
+ room: r.room,
293
+ name: r.name,
294
+ topic: r.topic,
295
+ members: r.members.map((x) => ({ instanceId: x.instanceId, name: x.name }))
296
+ }));
297
+ },
298
+ roomCommand: (hub, type, payload) => m.roomCommand(hub, type, payload),
299
+ postToRoom: (room, text) => m.postToRoom(room, text),
300
+ acceptPair: (ref, grant) => m.acceptPair(ref, grant),
301
+ denyPair: (ref) => m.denyPair(ref)
302
+ };
303
+ }
304
+ var NET_METHODS;
305
+ var init_net_api = __esm({
306
+ "src/net-api.ts"() {
307
+ "use strict";
308
+ NET_METHODS = [
309
+ "identity",
310
+ "instances",
311
+ "relayMembers",
312
+ "sendChat",
313
+ "askBrain",
314
+ "joinHub",
315
+ "addPeer",
316
+ "forgetPeer",
317
+ "probePair",
318
+ "initiatePair",
319
+ "requestRelayConnect",
320
+ "approveRelayConnect",
321
+ "denyRelayConnect",
322
+ "rooms",
323
+ "roomCommand",
324
+ "postToRoom",
325
+ "listPairRequests",
326
+ "acceptPair",
327
+ "denyPair"
328
+ ];
329
+ }
330
+ });
331
+
332
+ // src/daemon.ts
333
+ import { spawn } from "child_process";
334
+ import * as fs3 from "fs/promises";
335
+ import * as net from "net";
336
+ import * as path4 from "path";
337
+ async function serve(dir, node) {
338
+ const sock = socketPath(dir);
339
+ await fs3.mkdir(dir, { recursive: true });
340
+ await fs3.rm(sock, { force: true });
341
+ const server = net.createServer((conn) => {
342
+ let buffer = "";
343
+ conn.setEncoding("utf-8");
344
+ conn.on("data", (chunk) => {
345
+ buffer += chunk;
346
+ for (let nl = buffer.indexOf("\n"); nl >= 0; nl = buffer.indexOf("\n")) {
347
+ const line = buffer.slice(0, nl);
348
+ buffer = buffer.slice(nl + 1);
349
+ if (line.trim()) void handle(line, conn, node);
350
+ }
351
+ });
352
+ conn.on("error", () => void 0);
353
+ });
354
+ server.on("error", () => void 0);
355
+ await new Promise((done) => server.listen(sock, done));
356
+ return server;
357
+ }
358
+ async function handle(line, conn, node) {
359
+ let req;
360
+ try {
361
+ req = JSON.parse(line);
362
+ } catch {
363
+ return;
364
+ }
365
+ const reply = (r) => {
366
+ try {
367
+ conn.write(`${JSON.stringify({ id: req.id, ...r })}
368
+ `);
73
369
  } catch {
74
- this.messages = [];
75
- this.readAt = /* @__PURE__ */ new Map();
76
370
  }
371
+ };
372
+ if (!NET_METHODS.includes(req.method)) {
373
+ reply({ ok: false, error: `unknown method: ${req.method}` });
374
+ return;
375
+ }
376
+ try {
377
+ const fn = node[req.method];
378
+ reply({ ok: true, result: await fn(...req.args ?? []) });
379
+ } catch (err) {
380
+ reply({ ok: false, error: err instanceof Error ? err.message : String(err) });
77
381
  }
78
- /** Record a message. Returns false when this id was already recorded. */
79
- async add(m) {
80
- if (this.messages.some((x) => x.id === m.id)) return false;
81
- this.messages.push(m);
82
- if (this.messages.length > MAX_MESSAGES) {
83
- this.messages.splice(0, this.messages.length - MAX_MESSAGES);
382
+ }
383
+ function remoteNet(conn) {
384
+ let next = 1;
385
+ const pending = /* @__PURE__ */ new Map();
386
+ let buffer = "";
387
+ conn.setEncoding("utf-8");
388
+ conn.on("data", (chunk) => {
389
+ buffer += chunk;
390
+ for (let nl = buffer.indexOf("\n"); nl >= 0; nl = buffer.indexOf("\n")) {
391
+ const line = buffer.slice(0, nl);
392
+ buffer = buffer.slice(nl + 1);
393
+ if (!line.trim()) continue;
394
+ try {
395
+ const res = JSON.parse(line);
396
+ const waiter = pending.get(res.id);
397
+ if (!waiter) continue;
398
+ pending.delete(res.id);
399
+ if (res.ok) waiter.resolve(res.result);
400
+ else waiter.reject(new Error(res.error ?? "daemon error"));
401
+ } catch {
402
+ }
84
403
  }
85
- await this.persist();
86
- return true;
87
- }
88
- /** Everything with one peer, oldest first. */
89
- history(peerId, limit = 50) {
90
- return this.messages.filter((m) => m.peerId === peerId).slice(-limit);
91
- }
92
- /** Inbound messages the session has not been shown yet, oldest first. */
93
- unread() {
94
- return this.messages.filter((m) => m.dir === "in" && m.ts > (this.readAt.get(m.peerId) ?? 0));
95
- }
96
- /** Mark everything currently unread as seen. */
97
- async markRead() {
98
- for (const m of this.unread()) {
99
- const at = this.readAt.get(m.peerId) ?? 0;
100
- if (m.ts > at) this.readAt.set(m.peerId, m.ts);
404
+ });
405
+ const fail = (why) => {
406
+ for (const [, w] of pending) w.reject(new Error(why));
407
+ pending.clear();
408
+ };
409
+ conn.on("close", () => fail("the volenet daemon closed the connection"));
410
+ conn.on("error", (e) => fail(e.message));
411
+ const call = (method, ...args) => new Promise((resolve2, reject) => {
412
+ const id = next++;
413
+ pending.set(id, { resolve: resolve2, reject });
414
+ conn.write(`${JSON.stringify({ id, method, args })}
415
+ `);
416
+ });
417
+ return Object.fromEntries(
418
+ NET_METHODS.map((m) => [m, (...args) => call(m, ...args)])
419
+ );
420
+ }
421
+ async function connect(dir) {
422
+ return new Promise((resolve2) => {
423
+ const conn = net.createConnection(socketPath(dir));
424
+ const give = (ok) => {
425
+ conn.removeAllListeners("connect");
426
+ conn.removeAllListeners("error");
427
+ if (ok) resolve2(conn);
428
+ else {
429
+ conn.destroy();
430
+ resolve2(null);
431
+ }
432
+ };
433
+ conn.once("connect", () => give(true));
434
+ conn.once("error", () => give(false));
435
+ });
436
+ }
437
+ async function spawnDaemon(dir, env = {}) {
438
+ const entry = process.argv[1];
439
+ if (!entry) return null;
440
+ const child = spawn(process.execPath, [entry, "daemon"], {
441
+ detached: true,
442
+ stdio: "ignore",
443
+ env: { ...process.env, ...env, VOLENET_MCP_DIR: dir }
444
+ });
445
+ child.unref();
446
+ for (let i = 0; i < 40; i++) {
447
+ const conn = await connect(dir);
448
+ if (conn) return conn;
449
+ await new Promise((r) => setTimeout(r, 100));
450
+ }
451
+ return null;
452
+ }
453
+ var socketPath;
454
+ var init_daemon = __esm({
455
+ "src/daemon.ts"() {
456
+ "use strict";
457
+ init_net_api();
458
+ socketPath = (dir) => path4.join(dir, "daemon.sock");
459
+ }
460
+ });
461
+
462
+ // src/notify.ts
463
+ import { spawn as spawn2 } from "child_process";
464
+ function run(command, args) {
465
+ try {
466
+ const child = spawn2(command, args, { stdio: "ignore", detached: true });
467
+ child.on("error", () => void 0);
468
+ child.unref();
469
+ } catch {
470
+ }
471
+ }
472
+ function notifier(platform = process.platform) {
473
+ const setting = process.env.VOLENET_MCP_NOTIFY?.trim();
474
+ if (setting === "off") return () => void 0;
475
+ if (setting) return (title, body) => run(setting, [title, body]);
476
+ if (platform === "darwin") {
477
+ return (title, body) => run("osascript", [
478
+ "-e",
479
+ `display notification "${applescript(body)}" with title "${applescript(title)}"`
480
+ ]);
481
+ }
482
+ if (platform === "linux") return (title, body) => run("notify-send", [title, body]);
483
+ if (platform === "win32") {
484
+ return (title, body) => run("powershell", [
485
+ "-NoProfile",
486
+ "-Command",
487
+ `[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');$n=New-Object System.Windows.Forms.NotifyIcon;$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;$n.ShowBalloonTip(5000,'${title.replace(/'/g, "''")}','${body.replace(/'/g, "''")}',0)`
488
+ ]);
489
+ }
490
+ return () => void 0;
491
+ }
492
+ function preview(text, limit = 140) {
493
+ const flat = text.replace(/\s+/g, " ").trim();
494
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
495
+ }
496
+ var applescript;
497
+ var init_notify = __esm({
498
+ "src/notify.ts"() {
499
+ "use strict";
500
+ applescript = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
501
+ }
502
+ });
503
+
504
+ // src/node.ts
505
+ var node_exports = {};
506
+ __export(node_exports, {
507
+ alreadyTrusts: () => alreadyTrusts,
508
+ resolveSettings: () => resolveSettings,
509
+ runDaemon: () => runDaemon,
510
+ startLocal: () => startLocal,
511
+ startNode: () => startNode
512
+ });
513
+ import * as fsSync from "fs";
514
+ import * as path5 from "path";
515
+ import {
516
+ VoleNetManager,
517
+ createEventBus,
518
+ loadAuthorizedVoles,
519
+ parsePublicKey
520
+ } from "@openvole/volenet";
521
+ async function startNode(options) {
522
+ const inbox = new Inbox(options.dir, options.session);
523
+ await inbox.load();
524
+ if (process.env.VOLENET_MCP_NO_DAEMON !== "1") {
525
+ const conn = await connect(options.dir) ?? await spawnDaemon(options.dir);
526
+ if (conn) {
527
+ return {
528
+ net: remoteNet(conn),
529
+ inbox,
530
+ requests: [],
531
+ notices: [],
532
+ options,
533
+ hubStatus: options.hub ? `joined ${options.hub}` : "no hub configured",
534
+ canSample: false,
535
+ where: "daemon",
536
+ onMessage: watchLog(options.dir, inbox),
537
+ stop: async () => {
538
+ conn.destroy();
539
+ }
540
+ };
101
541
  }
102
- await this.persist();
103
- }
104
- /** Every peer we have said anything to or heard anything from, most recent first. */
105
- peers() {
106
- const by = /* @__PURE__ */ new Map();
107
- for (const m of this.messages) {
108
- const e = by.get(m.peerId) ?? { peerId: m.peerId, peerName: m.peerName, last: 0, unread: 0 };
109
- if (m.peerName) e.peerName = m.peerName;
110
- e.last = Math.max(e.last, m.ts);
111
- if (m.dir === "in" && m.ts > (this.readAt.get(m.peerId) ?? 0)) e.unread++;
112
- by.set(m.peerId, e);
542
+ }
543
+ const local = await startLocal(options, inbox);
544
+ return { ...local, where: "in-process" };
545
+ }
546
+ async function startLocal(options, inbox, notify) {
547
+ const bus = createEventBus();
548
+ const requests = [];
549
+ const notices = [];
550
+ const listeners = /* @__PURE__ */ new Set();
551
+ const port = await isFree(options.port) ? options.port : 0;
552
+ const manager = new VoleNetManager(
553
+ {
554
+ enabled: true,
555
+ instanceName: options.name,
556
+ role: "peer",
557
+ port,
558
+ keyPath: path5.join(options.dir, "net", "vole_key"),
559
+ // A hub is a peer we dial. 'read' rather than 'full': a hub carries our sealed traffic,
560
+ // it has no business acting on this node.
561
+ peers: options.hub ? [{ url: options.hub, trust: "read" }] : []
562
+ },
563
+ options.dir
564
+ );
565
+ bus.on("volenet:chat", (d) => {
566
+ const m = d;
567
+ const message = {
568
+ peerId: m.from,
569
+ peerName: m.fromName,
570
+ dir: "in",
571
+ text: m.text,
572
+ ts: m.timestamp,
573
+ id: m.messageId
574
+ };
575
+ void inbox.add(message).then((added) => {
576
+ if (!added) return;
577
+ for (const fn of listeners) fn(message);
578
+ notify?.(`${message.peerName} on VoleNet`, preview(message.text));
579
+ });
580
+ });
581
+ bus.on("volenet:chat:pending", (d) => {
582
+ const p = d;
583
+ for (const n of p.from ?? []) {
584
+ const at = notices.findIndex((x) => x.from === n.from);
585
+ if (at >= 0) notices[at] = n;
586
+ else notices.push(n);
113
587
  }
114
- return [...by.values()].sort((a, b) => b.last - a.last);
115
- }
116
- get size() {
117
- return this.messages.length;
118
- }
119
- /** Serialised: two messages arriving together must not race each other's rewrite. */
120
- persist() {
121
- this.writing = this.writing.then(async () => {
122
- const body = JSON.stringify(
123
- { messages: this.messages, readAt: Object.fromEntries(this.readAt) },
124
- null,
125
- 2
126
- );
127
- await fs2.mkdir(path2.dirname(this.file), { recursive: true });
128
- const tmp = `${this.file}.tmp`;
129
- await fs2.writeFile(tmp, body, "utf-8");
130
- await fs2.rename(tmp, this.file);
588
+ });
589
+ const remember = (kind) => (d) => {
590
+ const r = d;
591
+ if (requests.some((x) => x.from === r.from && x.kind === kind)) return;
592
+ requests.push({ kind, from: r.from, fromName: r.fromName, note: r.note, at: Date.now() });
593
+ };
594
+ bus.on("volenet:pair:request", remember("pair"));
595
+ bus.on("volenet:relay:request", remember("relay"));
596
+ await manager.start(void 0, bus);
597
+ const bound = manager.getTransport()?.getPort?.() ?? port;
598
+ const settings = { ...options, port: bound || options.port };
599
+ let hubStatus = "no hub configured";
600
+ if (options.hub) {
601
+ hubStatus = await alreadyTrusts(options.dir, options.hub) ? `joined ${options.hub}` : await join5(manager, options.hub);
602
+ }
603
+ return {
604
+ net: localNet(manager),
605
+ inbox,
606
+ requests,
607
+ notices,
608
+ options: settings,
609
+ hubStatus,
610
+ canSample: false,
611
+ onMessage: (fn) => {
612
+ listeners.add(fn);
613
+ return () => listeners.delete(fn);
614
+ },
615
+ stop: () => manager.stop()
616
+ };
617
+ }
618
+ async function runDaemon(options) {
619
+ const inbox = new Inbox(options.dir, "daemon");
620
+ await inbox.load();
621
+ const node = await startLocal(options, inbox, notifier());
622
+ await serve(options.dir, node.net);
623
+ await new Promise(() => void 0);
624
+ }
625
+ function watchLog(dir, inbox) {
626
+ return (fn) => {
627
+ const file2 = path5.join(dir, "messages.jsonl");
628
+ const seen = new Set(inbox.history("", 0).map((m) => m.id));
629
+ let closed = false;
630
+ const check = async () => {
631
+ if (closed) return;
632
+ const before = new Set(inbox.unread().map((m) => m.id));
633
+ await inbox.refresh();
634
+ for (const m of inbox.unread()) {
635
+ if (!before.has(m.id) && !seen.has(m.id)) {
636
+ seen.add(m.id);
637
+ fn(m);
638
+ }
639
+ }
640
+ };
641
+ let watcher;
642
+ try {
643
+ watcher = fsSync.watch(path5.dirname(file2), (_e, name) => {
644
+ if (name === "messages.jsonl") void check();
645
+ });
646
+ } catch {
647
+ }
648
+ const timer = setInterval(() => void check(), 1e3);
649
+ return () => {
650
+ closed = true;
651
+ clearInterval(timer);
652
+ watcher?.close();
653
+ };
654
+ };
655
+ }
656
+ async function join5(node, hub) {
657
+ const res = await node.initiateJoin(hub);
658
+ if (!res.ok) return `could not join ${hub}: ${res.error}`;
659
+ if (res.pending) return `waiting for approval at ${hub}`;
660
+ return `joined ${res.hubName ?? hub}`;
661
+ }
662
+ async function alreadyTrusts(dir, hub) {
663
+ try {
664
+ const r = await fetch(`${hub.replace(/\/$/, "")}/volenet/info`, {
665
+ signal: AbortSignal.timeout(8e3)
131
666
  });
132
- return this.writing;
667
+ const info = await r.json();
668
+ const parsed = info.publicKey ? parsePublicKey(info.publicKey) : null;
669
+ if (!parsed) return false;
670
+ return (await loadAuthorizedVoles(path5.join(dir, "net"))).has(parsed.instanceId);
671
+ } catch {
672
+ return false;
133
673
  }
134
- };
674
+ }
675
+ async function isFree(port) {
676
+ const netmod = await import("net");
677
+ return new Promise((resolve2) => {
678
+ const probe = netmod.createServer().once("error", () => resolve2(false)).once("listening", () => probe.close(() => resolve2(true))).listen(port, "0.0.0.0");
679
+ });
680
+ }
681
+ var init_node = __esm({
682
+ "src/node.ts"() {
683
+ "use strict";
684
+ init_config();
685
+ init_daemon();
686
+ init_inbox();
687
+ init_net_api();
688
+ init_notify();
689
+ }
690
+ });
691
+
692
+ // src/index.ts
693
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
694
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
695
+ import {
696
+ CallToolRequestSchema,
697
+ GetPromptRequestSchema,
698
+ ListPromptsRequestSchema,
699
+ ListToolsRequestSchema
700
+ } from "@modelcontextprotocol/sdk/types.js";
701
+
702
+ // src/cli.ts
703
+ init_config();
704
+ init_inbox();
705
+ import * as path6 from "path";
706
+ import { loadKeyPair } from "@openvole/volenet";
135
707
 
136
708
  // src/install.ts
137
709
  import { spawnSync } from "child_process";
@@ -147,15 +719,15 @@ function addArgs(scope, command) {
147
719
  }
148
720
  var NEXT_STEPS = '\nRestart Claude Code \u2014 MCP servers load at startup \u2014 then:\n\n volenet_whoami who you are on the mesh (an identity is made on first run)\n volenet_hub url:"..." join a hub, to be reachable from anywhere\n volenet_connect url:"..." or pair directly with an agent you can dial\n\nNothing else needs configuring.\n';
149
721
  function install(argv, out = process.stdout) {
150
- const scope = argv.includes("--user") ? "user" : "local";
722
+ const scope = argv.includes("--local") ? "local" : "user";
151
723
  const command = launchCommand();
152
724
  const paste = `claude mcp add ${SERVER_NAME} -s ${scope} -- ${command.join(" ")}`;
153
- const run2 = (args) => spawnSync("claude", args, {
725
+ const run3 = (args) => spawnSync("claude", args, {
154
726
  stdio: ["ignore", "pipe", "pipe"],
155
727
  encoding: "utf-8",
156
728
  timeout: 3e4
157
729
  });
158
- const listed = run2(["mcp", "list"]);
730
+ const listed = run3(["mcp", "list"]);
159
731
  if (listed.error) {
160
732
  out.write(
161
733
  `The \`claude\` CLI is not on PATH. Run this once, in the project you want it in:
@@ -171,7 +743,7 @@ function install(argv, out = process.stdout) {
171
743
  ${NEXT_STEPS}`);
172
744
  return 0;
173
745
  }
174
- const added = run2(addArgs(scope, command));
746
+ const added = run3(addArgs(scope, command));
175
747
  if (added.status !== 0) {
176
748
  out.write(
177
749
  `Could not register it automatically${added.stderr ? `: ${added.stderr.trim()}` : ""}
@@ -184,25 +756,30 @@ Run this once instead:
184
756
  );
185
757
  return added.status ?? 1;
186
758
  }
187
- out.write(`Registered ${SERVER_NAME} (${scope} scope).
188
- ${NEXT_STEPS}`);
759
+ out.write(
760
+ `Registered ${SERVER_NAME} (${scope} scope${scope === "user" ? " \u2014 available in every project" : ", this project only"}).
761
+ ${NEXT_STEPS}`
762
+ );
189
763
  return 0;
190
764
  }
191
765
 
192
766
  // src/cli.ts
193
767
  var USAGE = `volenet-mcp \u2014 VoleNet as an MCP server
194
768
 
195
- volenet-mcp install [--user] register with Claude Code (default: this project)
769
+ volenet-mcp install [--local] register with Claude Code (default: every project)
196
770
  volenet-mcp whoami this machine's identity on the mesh
771
+ volenet-mcp daemon run the node in the foreground (normally started for you)
197
772
  volenet-mcp hub [url|--leave] which hub to use; takes effect on the next session
198
- volenet-mcp inbox [--read] messages waiting, for catching up or a SessionStart hook
773
+ volenet-mcp inbox [--read] [--quiet]
774
+ messages waiting. --read marks them seen, --quiet says
775
+ nothing when there are none (for hooks)
199
776
 
200
777
  With no command it runs as the MCP server itself, over stdio, which is how Claude Code starts it.
201
778
  Anything needing a live node \u2014 peers, pairing, asking an agent's brain \u2014 is a tool you ask for in
202
779
  a session, not a command here.
203
780
  `;
204
781
  var when = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 16);
205
- async function run(argv, out = process.stdout) {
782
+ async function run2(argv, out = process.stdout) {
206
783
  const [command, ...rest] = argv;
207
784
  const dir = defaultDir();
208
785
  if (!command || command === "help" || command === "--help" || command === "-h") {
@@ -210,9 +787,15 @@ async function run(argv, out = process.stdout) {
210
787
  return 0;
211
788
  }
212
789
  if (command === "install") return install(rest, out);
790
+ if (command === "daemon") {
791
+ const { runDaemon: runDaemon2 } = await Promise.resolve().then(() => (init_node(), node_exports));
792
+ const { resolveSettings: resolveSettings2 } = await Promise.resolve().then(() => (init_config(), config_exports));
793
+ await runDaemon2(await resolveSettings2());
794
+ return 0;
795
+ }
213
796
  if (command === "whoami") {
214
797
  const stored = await loadStored(dir);
215
- const keys = await loadKeyPair(path4.join(dir, "net")).catch(() => null);
798
+ const keys = await loadKeyPair(path6.join(dir, "net")).catch(() => null);
216
799
  if (!keys) {
217
800
  out.write(
218
801
  `No identity yet at ${dir}.
@@ -261,11 +844,11 @@ It is joined the next time the server starts \u2014 restart Claude Code, or ask
261
844
  return 0;
262
845
  }
263
846
  if (command === "inbox") {
264
- const inbox = new Inbox(path4.join(dir, "inbox.json"));
847
+ const inbox = new Inbox(dir, process.env.VOLENET_MCP_SESSION?.trim() || sessionKey());
265
848
  await inbox.load();
266
849
  const unread = inbox.unread();
267
850
  if (unread.length === 0) {
268
- out.write("No new messages.\n");
851
+ if (!rest.includes("--quiet")) out.write("No new messages.\n");
269
852
  return 0;
270
853
  }
271
854
  out.write(`${unread.length} new VoleNet message${unread.length === 1 ? "" : "s"}:
@@ -285,103 +868,149 @@ ${USAGE}`);
285
868
  return 1;
286
869
  }
287
870
 
288
- // src/node.ts
289
- import * as path5 from "path";
290
- import {
291
- VoleNetManager,
292
- createEventBus,
293
- loadAuthorizedVoles,
294
- parsePublicKey
295
- } from "@openvole/volenet";
296
- async function startNode(options) {
297
- const bus = createEventBus();
298
- const inbox = new Inbox(path5.join(options.dir, "inbox.json"));
299
- await inbox.load();
300
- const requests = [];
301
- const notices = [];
302
- const net = new VoleNetManager(
303
- {
304
- enabled: true,
305
- instanceName: options.name,
306
- role: "peer",
307
- port: options.port,
308
- keyPath: path5.join(options.dir, "net", "vole_key"),
309
- // A hub is a peer we dial. 'read' rather than 'full': a hub carries our sealed traffic,
310
- // it has no business acting on this node.
311
- peers: options.hub ? [{ url: options.hub, trust: "read" }] : []
312
- },
313
- options.dir
314
- );
315
- const listeners = /* @__PURE__ */ new Set();
316
- bus.on("volenet:chat", (d) => {
317
- const m = d;
318
- const message = {
319
- peerId: m.from,
320
- peerName: m.fromName,
321
- dir: "in",
322
- text: m.text,
323
- ts: m.timestamp,
324
- id: m.messageId
325
- };
326
- void inbox.add(message).then((added) => {
327
- if (added) for (const fn of listeners) fn(message);
328
- });
329
- });
330
- bus.on("volenet:chat:pending", (d) => {
331
- const p = d;
332
- for (const n of p.from ?? []) {
333
- const at = notices.findIndex((x) => x.from === n.from);
334
- if (at >= 0) notices[at] = n;
335
- else notices.push(n);
336
- }
337
- });
338
- const remember = (kind) => (d) => {
339
- const r = d;
340
- if (requests.some((x) => x.from === r.from && x.kind === kind)) return;
341
- requests.push({ kind, from: r.from, fromName: r.fromName, note: r.note, at: Date.now() });
342
- };
343
- bus.on("volenet:pair:request", remember("pair"));
344
- bus.on("volenet:relay:request", remember("relay"));
345
- await net.start(void 0, bus);
346
- let hubStatus = "no hub configured";
347
- if (options.hub) {
348
- hubStatus = await alreadyTrusts(options.dir, options.hub) ? `joined ${options.hub}` : await join4(net, options.hub);
349
- }
350
- return {
351
- net,
352
- inbox,
353
- requests,
354
- notices,
355
- options,
356
- hubStatus,
357
- onMessage: (fn) => {
358
- listeners.add(fn);
359
- return () => listeners.delete(fn);
360
- },
361
- stop: () => net.stop()
362
- };
363
- }
364
- async function join4(net, hub) {
365
- const res = await net.initiateJoin(hub);
366
- if (!res.ok) return `could not join ${hub}: ${res.error}`;
367
- if (res.pending) return `waiting for approval at ${hub}`;
368
- return `joined ${res.hubName ?? hub}`;
369
- }
370
- async function alreadyTrusts(dir, hub) {
371
- try {
372
- const r = await fetch(`${hub.replace(/\/$/, "")}/volenet/info`, {
373
- signal: AbortSignal.timeout(8e3)
374
- });
375
- const info = await r.json();
376
- const parsed = info.publicKey ? parsePublicKey(info.publicKey) : null;
377
- if (!parsed) return false;
378
- return (await loadAuthorizedVoles(path5.join(dir, "net"))).has(parsed.instanceId);
379
- } catch {
380
- return false;
871
+ // src/index.ts
872
+ init_node();
873
+
874
+ // src/prompts.ts
875
+ var PROMPTS = [
876
+ {
877
+ name: "whoami",
878
+ description: "This session\u2019s identity on the VoleNet mesh, and whether it can reach anything.",
879
+ render: () => `Report this session's VoleNet identity.
880
+
881
+ Call \`volenet_whoami\`. Give back the name, the instance id and where it is listening, and say in
882
+ one line whether it is actually reachable \u2014 a hub joined, or peers paired \u2014 rather than leaving an
883
+ empty roster to be read as a failure.
884
+
885
+ The instance id is what someone else needs to grant this session anything: an agent's operator names
886
+ it in \`net.peers\`. Offer it if they look like they need it. The public key is several kilobytes of
887
+ post-quantum key material, so ask for it with \`key: true\` only when a peer actually wants it.
888
+
889
+ If nothing is connected, say so and offer \`setup\`.`
890
+ },
891
+ {
892
+ name: "peers",
893
+ description: "Who this session can reach right now, and by which route.",
894
+ render: () => `List who this session can reach on VoleNet.
895
+
896
+ Call \`volenet_peers\`. For each one say whether it is online, and whether the link is direct or
897
+ through a hub \u2014 the difference matters: a hub carries chat and consent, a direct link is the only
898
+ route that can ask an agent's brain.
899
+
900
+ Flag anything that needs an action rather than only listing state: a hub member with no consent yet
901
+ cannot be messaged until one side asks (\`volenet_connect\`), and someone offline will receive what
902
+ is sent whenever they return. If the list is empty, say why \u2014 no hub, no pairings \u2014 and offer
903
+ \`setup\`.`
904
+ },
905
+ {
906
+ name: "rooms",
907
+ description: "Rooms this session is in, and how to say something to one.",
908
+ render: () => `Show the VoleNet rooms this session is in.
909
+
910
+ Call \`volenet_room\` with no arguments. For each, say who is in it \u2014 a room is several people and
911
+ agents in one conversation, so who else is there is the useful part.
912
+
913
+ To say something, \`volenet_room\` with \`post\` and \`room\`. Every member gets their own sealed copy;
914
+ there is no shared key, which is why removing somebody stops them reading immediately. Report what
915
+ came back honestly: some copies may be waiting for members who are away, and some may not have been
916
+ sent at all because that member has not accepted this session \u2014 **a room does not create consent**,
917
+ so say that rather than let it read as a failure.
918
+
919
+ If there are no rooms, offer to make one (\`create\`) or to join one by id (\`join\`). A room lives on
920
+ a hub, so one has to be joined first.`
921
+ },
922
+ {
923
+ name: "setup",
924
+ description: "Get this session onto the VoleNet mesh \u2014 join a hub, or pair with an agent.",
925
+ render: () => `Get this session onto the VoleNet mesh.
926
+
927
+ 1. Call \`volenet_whoami\` first. It reports the identity, whether a hub is set, and how many peers
928
+ are reachable. An identity is generated on first run; there is nothing to create.
929
+ 2. If nothing is connected, explain the two routes and ask which is wanted \u2014 do not pick silently:
930
+ - **A hub** (\`volenet_hub\`) makes this session reachable from anywhere, including from a phone,
931
+ and works when neither side can dial the other. A hub carries sealed traffic it cannot read and
932
+ stores no message. It will **not** relay a question to an agent's brain.
933
+ - **A direct pair** (\`volenet_connect\`) with an agent whose address is reachable from here. This
934
+ is the only route that can ask an agent's brain.
935
+ Both can be used at once, and either can be added later.
936
+ 3. Carry out whichever they choose. For a hub, the URL is enough. For a pair, follow the two-step
937
+ fingerprint check \u2014 the \`pair\` command covers it.
938
+ 4. Finish by calling \`volenet_peers\` and saying plainly who is now reachable, and by which route.
939
+
940
+ Reaching someone also needs consent, which is separate from being connected: on a hub, either side
941
+ asks and the other accepts. Say so, rather than letting an empty roster look like a failure.`
942
+ },
943
+ {
944
+ name: "catch-up",
945
+ description: "Read what arrived while this session was away, and say what needs answering.",
946
+ render: () => `Catch up on VoleNet.
947
+
948
+ 1. Call \`volenet_inbox\`. It returns messages that arrived \u2014 including while no session was running,
949
+ since senders hold what they could not deliver and flush on reconnect \u2014 and who tried to reach
950
+ this session while it was away. Reading marks them seen.
951
+ 2. Call \`volenet_peers\` if anything needs context about who a sender is.
952
+ 3. Summarise for the person: who wrote, what they want, and what is worth answering. Do not reply on
953
+ their behalf without asking.
954
+ 4. If a reply is wanted, \`volenet_send\` says it and \`volenet_wait\` waits for what comes back, so
955
+ an exchange happens in one turn rather than by checking again later.
956
+
957
+ If nothing arrived, say so in one line. This is worth running at the start of a session.`
958
+ },
959
+ {
960
+ name: "pair",
961
+ description: "Pair with an agent at a URL, checking the fingerprint before trusting it.",
962
+ arguments: [
963
+ {
964
+ name: "url",
965
+ description: "The agent to pair with, e.g. http://10.0.0.5:9700",
966
+ required: true
967
+ }
968
+ ],
969
+ render: (a) => `Pair this session with the VoleNet node at ${a.url ?? "<url>"}.
970
+
971
+ Pairing is deliberately two calls, because trusting a URL blind is trusting whoever holds it.
972
+
973
+ 1. Call \`volenet_connect\` with \`url: "${a.url ?? "<url>"}"\`. It reaches the node and reports the
974
+ fingerprint of whoever answered. It trusts nothing yet.
975
+ 2. Show that fingerprint to the person and ask them to check it against what the other side reports
976
+ \u2014 \`vole net show-key\` on an OpenVole agent. **Wait for them.** Do not confirm on their behalf:
977
+ this step exists precisely so a human compares two values.
978
+ 3. Ask whether this session should also be able to use that agent's **brain** \u2014 running its model
979
+ to answer questions \u2014 or only chat with whoever runs it.
980
+ 4. Once they confirm the fingerprint, call \`volenet_connect\` again with the same \`url\`,
981
+ \`confirm:\` set to that fingerprint, and \`brain: true\` if they said yes. This trusts the node
982
+ and sends a pair request carrying the ask.
983
+ 5. Tell them the request now waits for the operator of that node to accept it, and that nothing
984
+ arrives until they do.
985
+
986
+ Being trusted is not the same as being allowed to do anything: the keystore says who may connect,
987
+ \`net.peers\` says what they may then do. Sending the ask with the request is what lets the operator
988
+ settle both while accepting, instead of editing a config file afterwards.`
989
+ },
990
+ {
991
+ name: "reach",
992
+ description: "Message a peer and wait for the reply, rather than checking back later.",
993
+ arguments: [
994
+ { name: "peer", description: "Who to reach \u2014 a name or instance id", required: true },
995
+ { name: "message", description: "What to say", required: false }
996
+ ],
997
+ render: (a) => `Reach ${a.peer ?? "a peer"} over VoleNet${a.message ? ` and say: ${a.message}` : ""}.
998
+
999
+ 1. \`volenet_peers\` first if unsure the name resolves, or by which route they are reachable.
1000
+ 2. \`volenet_send\` to say it. This is chat: it reaches whoever is there and does **not** run their
1001
+ brain. To ask an agent's model instead, use \`volenet_ask\` \u2014 direct links only, and its operator
1002
+ must have granted brain access.
1003
+ 3. \`volenet_wait\` for the answer, so the exchange completes in this turn. If nothing comes back in
1004
+ time, say so plainly: the message is not lost, and a reply lands in the inbox whenever it comes.
1005
+
1006
+ If the peer is offline the message waits here and goes out when they return \u2014 report that rather
1007
+ than treating it as a failure.`
381
1008
  }
382
- }
1009
+ ];
383
1010
 
384
1011
  // src/tools.ts
1012
+ init_config();
1013
+ init_node();
385
1014
  var obj = (properties, required = []) => ({
386
1015
  type: "object",
387
1016
  properties,
@@ -390,18 +1019,15 @@ var obj = (properties, required = []) => ({
390
1019
  var str = (description) => ({ type: "string", description });
391
1020
  var num = (description) => ({ type: "number", description });
392
1021
  var when2 = (ts) => ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : "-";
393
- function peers(node) {
394
- const live = new Set(
395
- (node.net.getTransport()?.getPeers() ?? []).filter((p) => p.connected).map((p) => p.peerId)
396
- );
397
- const out = node.net.getInstances().map((i) => ({
1022
+ async function peers(node) {
1023
+ const out = (await node.net.instances()).map((i) => ({
398
1024
  id: i.id,
399
1025
  name: i.name,
400
1026
  route: "direct",
401
- connected: live.has(i.id)
1027
+ connected: i.connected
402
1028
  }));
403
1029
  const direct = new Set(out.map((p) => p.id));
404
- for (const m of node.net.getRelayMembers()) {
1030
+ for (const m of await node.net.relayMembers()) {
405
1031
  if (direct.has(m.id)) continue;
406
1032
  out.push({
407
1033
  id: m.id,
@@ -414,8 +1040,8 @@ function peers(node) {
414
1040
  }
415
1041
  return out;
416
1042
  }
417
- function resolve(node, ref) {
418
- const all = peers(node);
1043
+ async function resolve(node, ref) {
1044
+ const all = await peers(node);
419
1045
  return all.find((p) => p.id === ref) ?? all.find((p) => p.name === ref) ?? all.find((p) => p.id.startsWith(ref)) ?? all.find((p) => p.name.toLowerCase() === ref.toLowerCase());
420
1046
  }
421
1047
  var TOOLS = [
@@ -426,16 +1052,20 @@ var TOOLS = [
426
1052
  key: { type: "boolean", description: "Include the full public key string" }
427
1053
  }),
428
1054
  async run(node, args) {
429
- const key = node.net.getKeyPair();
430
- const online = peers(node).filter((p) => p.connected).length;
1055
+ const key = await node.net.identity();
1056
+ const online = (await peers(node)).filter((p) => p.connected).length;
431
1057
  const lines = [
432
1058
  `name ${node.options.name}`,
433
1059
  `instanceId ${key?.instanceId ?? "(not started)"}`,
434
1060
  `hub ${node.hubStatus}`,
435
1061
  `connected ${online} peer(s) online`,
436
1062
  `listening port ${node.options.port} (reachable only from networks that can dial it)`,
437
- `store ${node.options.dir}`
1063
+ `store ${node.options.dir}`,
1064
+ `node ${node.where === "daemon" ? "a daemon, so this identity stays reachable when no session is open" : "in this session, so it is only reachable while this session is"}`
438
1065
  ];
1066
+ lines.push(
1067
+ `replies ${node.canSample ? "this client can be asked to answer on its own" : "only when you ask \u2014 this client cannot be woken by a message"}`
1068
+ );
439
1069
  if (args.key) lines.push("", `publicKey ${key?.publicKeyString ?? "-"}`);
440
1070
  else
441
1071
  lines.push(
@@ -455,7 +1085,7 @@ var TOOLS = [
455
1085
  description: "Everyone this session can reach: agents and people, whether the link is direct or through a hub, and whether they are online right now.",
456
1086
  inputSchema: obj({}),
457
1087
  async run(node) {
458
- const all = peers(node);
1088
+ const all = await peers(node);
459
1089
  if (all.length === 0) {
460
1090
  return "No peers. Join a hub (VOLENET_MCP_HUB) or pair with a node directly (volenet_pair).";
461
1091
  }
@@ -512,7 +1142,7 @@ var TOOLS = [
512
1142
  const to = String(args.to ?? "");
513
1143
  const text = String(args.text ?? "");
514
1144
  if (!text.trim()) return "Nothing to send.";
515
- const peer = resolve(node, to);
1145
+ const peer = await resolve(node, to);
516
1146
  const res = await node.net.sendChat(peer?.id ?? to, text);
517
1147
  if (!res.ok) return `Not sent: ${res.error ?? "unknown error"}`;
518
1148
  await node.inbox.add({
@@ -537,7 +1167,7 @@ var TOOLS = [
537
1167
  limit: num("Messages (default 50)")
538
1168
  }),
539
1169
  async run(node, args) {
540
- const peer = resolve(node, String(args.peer ?? ""));
1170
+ const peer = await resolve(node, String(args.peer ?? ""));
541
1171
  const id = peer?.id ?? String(args.peer ?? "");
542
1172
  const msgs = node.inbox.history(id, Number(args.limit ?? 50));
543
1173
  if (msgs.length === 0) return `Nothing recorded with ${peer?.name ?? id}.`;
@@ -556,16 +1186,15 @@ var TOOLS = [
556
1186
  ["to", "question"]
557
1187
  ),
558
1188
  async run(node, args) {
559
- const peer = resolve(node, String(args.to ?? ""));
1189
+ const peer = await resolve(node, String(args.to ?? ""));
560
1190
  if (!peer) return `No peer found: "${args.to}". Use volenet_peers.`;
561
1191
  if (peer.route !== "direct") {
562
1192
  return `${peer.name} is only reachable through a hub, and a hub will not relay a question to an agent's brain \u2014 it carries chat and consent only. Use volenet_send, or pair directly with volenet_pair.`;
563
1193
  }
564
- const mgr = node.net.getRemoteTaskManager();
565
- if (!mgr) return "Remote task manager not available.";
566
- const res = await mgr.delegateTask(
1194
+ const res = await node.net.askBrain(
567
1195
  peer.id,
568
- { taskId: "", input: String(args.question ?? ""), fromName: node.options.name },
1196
+ String(args.question ?? ""),
1197
+ node.options.name,
569
1198
  Number(args.timeout_ms ?? 12e4)
570
1199
  );
571
1200
  if (res.status === "completed") return `${peer.name} says:
@@ -575,7 +1204,7 @@ ${res.result}`;
575
1204
  if (typeof why === "string" && why.includes("allowBrain")) {
576
1205
  return `${peer.name} refused: ${why}
577
1206
 
578
- Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.instanceId}", "trust": "read", "allowBrain": true } to net.peers and restarting.`;
1207
+ Its operator can allow this session by adding { "id": "${(await node.net.identity())?.instanceId}", "trust": "read", "allowBrain": true } to net.peers and restarting.`;
579
1208
  }
580
1209
  return `${peer.name} did not answer: ${why}`;
581
1210
  }
@@ -598,7 +1227,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
598
1227
  node.requests.splice(node.requests.indexOf(req), 1);
599
1228
  return ok.ok ? `${accept ? "Accepted" : "Denied"} ${req.fromName}.` : `Failed: ${"error" in ok ? ok.error : "unknown"}`;
600
1229
  }
601
- const pairs = node.net.listPairRequests();
1230
+ const pairs = await node.net.listPairRequests();
602
1231
  for (const p of pairs) {
603
1232
  if (!node.requests.some((r) => r.from === p.id)) {
604
1233
  node.requests.push({ kind: "pair", from: p.id, fromName: p.name, at: Date.now() });
@@ -619,7 +1248,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
619
1248
  }),
620
1249
  async run(node, args) {
621
1250
  const limit = Math.min(Math.max(Number(args.timeout_ms ?? 6e4), 1e3), 3e5);
622
- const want = args.from ? resolve(node, String(args.from)) : void 0;
1251
+ const want = args.from ? await resolve(node, String(args.from)) : void 0;
623
1252
  const wanted = (m) => !args.from || m.peerId === (want?.id ?? args.from);
624
1253
  const already = node.inbox.unread().filter(wanted);
625
1254
  if (already.length === 0) {
@@ -655,7 +1284,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
655
1284
  if (args.leave) {
656
1285
  const hub = node.options.hub;
657
1286
  if (!hub) return "Not on a hub.";
658
- node.net.forgetPeer(hub);
1287
+ await node.net.forgetPeer(hub);
659
1288
  node.options.hub = void 0;
660
1289
  node.hubStatus = "no hub configured";
661
1290
  await saveStored(node.options.dir, { hub: void 0 });
@@ -674,7 +1303,7 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
674
1303
  await saveStored(node.options.dir, { hub: url });
675
1304
  return `Joined ${url} again \u2014 it was already trusted, so no introduction was needed.`;
676
1305
  }
677
- const res = await node.net.initiateJoin(url);
1306
+ const res = await node.net.joinHub(url);
678
1307
  if (!res.ok) return `Could not join ${url}: ${res.error}`;
679
1308
  node.options.hub = url;
680
1309
  node.hubStatus = res.pending ? `waiting for approval at ${url}` : `joined ${res.hubName ?? url}`;
@@ -682,12 +1311,84 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
682
1311
  return res.pending ? `Asked to join ${url}. Its operator has to approve before you appear in the roster.` : `Joined ${res.hubName ?? url}. Remembered, so the next session starts here. Call volenet_peers to see who is around.`;
683
1312
  }
684
1313
  },
1314
+ {
1315
+ name: "volenet_room",
1316
+ description: "Rooms: several people and agents in one conversation. With no arguments it lists the rooms this session is in. Give `post` to say something to a room \u2014 every member gets their own sealed copy, so there is no shared key and removing someone stops them reading immediately. Give `create`, `join`, `leave` or `invite` to change membership, which the hub keeps. A room does not create consent: a member who has not accepted you will not receive your posts.",
1317
+ inputSchema: obj({
1318
+ room: str("Which room, by id or name (see the list)"),
1319
+ post: str("Say this to the room"),
1320
+ create: str("Make a room with this name"),
1321
+ join: str("Join a room by id"),
1322
+ leave: { type: "boolean", description: "Leave the room named in `room`" },
1323
+ invite: str("Bring this peer (name or id) into the room named in `room`"),
1324
+ hub: str("Which hub holds the room. Only needed with more than one.")
1325
+ }),
1326
+ async run(node, args) {
1327
+ const hubOf = async () => {
1328
+ if (args.hub) return String(args.hub);
1329
+ const hubs = (await node.net.instances()).filter((i) => i.connected);
1330
+ if (hubs.length === 0) return null;
1331
+ return hubs[0].id;
1332
+ };
1333
+ const find = async (ref) => {
1334
+ const all2 = await node.net.rooms();
1335
+ return all2.find((r) => r.room === ref) ?? all2.find((r) => r.name === ref);
1336
+ };
1337
+ if (args.create || args.join || args.leave || args.invite) {
1338
+ const hub = await hubOf();
1339
+ if (!hub)
1340
+ return "No hub connected. A room lives on a hub \u2014 join one first with volenet_hub.";
1341
+ if (args.create) {
1342
+ const res2 = await node.net.roomCommand(hub, "room:create", { name: String(args.create) });
1343
+ return res2.ok ? `Asked for a room called "${args.create}". Call this again in a moment to see it.` : `Could not: ${res2.error}`;
1344
+ }
1345
+ if (args.join) {
1346
+ const res2 = await node.net.roomCommand(hub, "room:join", { room: String(args.join) });
1347
+ return res2.ok ? `Asked to join ${args.join}.` : `Could not: ${res2.error}`;
1348
+ }
1349
+ const room = args.room ? await find(String(args.room)) : void 0;
1350
+ if (!room) return "Name the room with `room` \u2014 see the list.";
1351
+ if (args.leave) {
1352
+ const res2 = await node.net.roomCommand(hub, "room:leave", { room: room.room });
1353
+ return res2.ok ? `Left ${room.name}.` : `Could not: ${res2.error}`;
1354
+ }
1355
+ const peer = await resolve(node, String(args.invite));
1356
+ const res = await node.net.roomCommand(hub, "room:invite", {
1357
+ room: room.room,
1358
+ member: peer?.id ?? String(args.invite)
1359
+ });
1360
+ return res.ok ? `Invited ${peer?.name ?? args.invite} to ${room.name}.` : `Could not: ${res.error}`;
1361
+ }
1362
+ if (args.post) {
1363
+ const room = args.room ? await find(String(args.room)) : (await node.net.rooms())[0];
1364
+ if (!room) return "No room to post to. Create or join one first.";
1365
+ const res = await node.net.postToRoom(room.room, String(args.post));
1366
+ if (!res.ok) return `Not posted: ${res.error}`;
1367
+ const bits = [`Posted to ${room.name}: ${res.sent} delivered`];
1368
+ if (res.held) bits.push(`${res.held} waiting for members who are away`);
1369
+ if (res.skipped)
1370
+ bits.push(`${res.skipped} could not be reached \u2014 they may not have accepted you`);
1371
+ return `${bits.join(", ")}.`;
1372
+ }
1373
+ const all = await node.net.rooms();
1374
+ if (all.length === 0) {
1375
+ return 'Not in any room. Create one with create:"name", or join one you have been given the id for.';
1376
+ }
1377
+ return all.map(
1378
+ (r) => ` ${r.name} ${r.room.substring(0, 8)} ${r.members.length} member(s): ${r.members.map((m) => m.name).join(", ")}`
1379
+ ).join("\n");
1380
+ }
1381
+ },
685
1382
  {
686
1383
  name: "volenet_connect",
687
1384
  description: "Reach out to someone new: pair directly with a node at a URL, or ask a hub member for consent to chat. Pairing is two calls \u2014 the first reports the fingerprint of whoever answers, the second confirms it \u2014 because trusting a URL blind is trusting whoever holds it. Neither side trusts you until they accept.",
688
1385
  inputSchema: obj({
689
1386
  url: str("Node URL to pair with directly, e.g. http://10.0.0.5:9700"),
690
1387
  confirm: str("The fingerprint returned by a first call with url, confirming who answers"),
1388
+ brain: {
1389
+ type: "boolean",
1390
+ description: "Also ask for permission to use that agent's brain. The operator sees it as part of the same decision and can grant it while accepting; without it, being trusted allows chat only."
1391
+ },
691
1392
  member: str("Hub member name or id to ask for chat consent"),
692
1393
  note: str("A line saying who you are")
693
1394
  }),
@@ -711,8 +1412,18 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
711
1412
  if (!probe.fingerprint?.startsWith(confirm)) {
712
1413
  return `That fingerprint does not match: it answers with ${probe.fingerprint}. Nothing was trusted.`;
713
1414
  }
714
- const res = await node.net.initiatePair(url, probe.publicKey, note);
715
- return res.ok ? `Trusted ${probe.name ?? url} and asked it to trust this session. Nothing arrives until their operator accepts.` : `Could not ask: ${res.error}`;
1415
+ const res = await node.net.initiatePair(
1416
+ url,
1417
+ probe.publicKey,
1418
+ note,
1419
+ args.brain ? ["brain"] : void 0
1420
+ );
1421
+ if (!res.ok) return `Could not ask: ${res.error}`;
1422
+ return [
1423
+ `Trusted ${probe.name ?? url} and asked it to trust this session.`,
1424
+ args.brain ? "The request also asks to use its brain, so its operator can grant that while accepting \u2014 no config editing, no restart." : "It asks for trust only. Pass brain:true to also ask for brain access.",
1425
+ "Nothing arrives until their operator accepts."
1426
+ ].join(" ");
716
1427
  }
717
1428
  if (args.member) {
718
1429
  const res = await node.net.requestRelayConnect(String(args.member), note);
@@ -725,6 +1436,9 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
725
1436
  ];
726
1437
 
727
1438
  // src/index.ts
1439
+ init_inbox();
1440
+ init_config();
1441
+ init_node();
728
1442
  function unreadFooter(node, toolName) {
729
1443
  if (toolName === "volenet_inbox" || toolName === "volenet_wait") return "";
730
1444
  const unread = node.inbox.unread();
@@ -734,8 +1448,34 @@ function unreadFooter(node, toolName) {
734
1448
 
735
1449
  \u2014 ${unread.length} unread message${unread.length === 1 ? "" : "s"} from ${who}. Read them with volenet_inbox.`;
736
1450
  }
737
- function createServer(node) {
738
- const server = new Server({ name: "volenet", version: "0.1.0" }, { capabilities: { tools: {} } });
1451
+ function createServer2(node) {
1452
+ const server = new Server(
1453
+ { name: "volenet", version: "0.1.0" },
1454
+ { capabilities: { tools: {}, prompts: {} } }
1455
+ );
1456
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1457
+ prompts: PROMPTS.map((p) => ({
1458
+ name: p.name,
1459
+ description: p.description,
1460
+ ...p.arguments ? { arguments: p.arguments } : {}
1461
+ }))
1462
+ }));
1463
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1464
+ const prompt = PROMPTS.find((p) => p.name === request.params.name);
1465
+ if (!prompt) throw new Error(`No such prompt: ${request.params.name}`);
1466
+ return {
1467
+ description: prompt.description,
1468
+ messages: [
1469
+ {
1470
+ role: "user",
1471
+ content: {
1472
+ type: "text",
1473
+ text: prompt.render(request.params.arguments ?? {})
1474
+ }
1475
+ }
1476
+ ]
1477
+ };
1478
+ });
739
1479
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
740
1480
  tools: TOOLS.map((t) => ({
741
1481
  name: t.name,
@@ -765,13 +1505,31 @@ function createServer(node) {
765
1505
  });
766
1506
  return server;
767
1507
  }
1508
+ async function recordClientCapabilities(dir, caps) {
1509
+ try {
1510
+ const fs4 = await import("fs/promises");
1511
+ const path7 = await import("path");
1512
+ await fs4.mkdir(dir, { recursive: true });
1513
+ await fs4.writeFile(
1514
+ path7.join(dir, "client.json"),
1515
+ `${JSON.stringify(caps ?? {}, null, 2)}
1516
+ `,
1517
+ "utf-8"
1518
+ );
1519
+ } catch {
1520
+ }
1521
+ }
768
1522
  async function main() {
769
1523
  const options = await resolveSettings();
770
1524
  const node = await startNode(options);
771
- const server = createServer(node);
1525
+ const server = createServer2(node);
772
1526
  await server.connect(new StdioServerTransport());
1527
+ const caps = server.getClientCapabilities();
1528
+ node.canSample = Boolean(caps && typeof caps === "object" && "sampling" in caps);
1529
+ await recordClientCapabilities(options.dir, caps);
1530
+ const me = await node.net.identity().catch(() => null);
773
1531
  process.stderr.write(
774
- `volenet-mcp: ${options.name} (${node.net.getKeyPair()?.instanceId.substring(0, 8)}) ready${options.hub ? ` \u2014 hub ${options.hub}` : " \u2014 no hub configured"}
1532
+ `volenet-mcp: ${options.name} (${me?.instanceId.substring(0, 8) ?? "?"}) ready \u2014 node ${node.where}${options.hub ? `, hub ${options.hub}` : ", no hub configured"}
775
1533
  `
776
1534
  );
777
1535
  let stopping = false;
@@ -787,7 +1545,7 @@ async function main() {
787
1545
  }
788
1546
  if (process.argv[1]?.includes("volenet-mcp") || process.env.VOLENET_MCP_RUN === "1") {
789
1547
  if (process.argv[2] || process.stdin.isTTY) {
790
- run(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1548
+ run2(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
791
1549
  process.stderr.write(`volenet-mcp: ${err instanceof Error ? err.message : String(err)}
792
1550
  `);
793
1551
  process.exit(1);
@@ -802,13 +1560,15 @@ if (process.argv[1]?.includes("volenet-mcp") || process.env.VOLENET_MCP_RUN ===
802
1560
  }
803
1561
  export {
804
1562
  Inbox,
1563
+ PROMPTS,
805
1564
  TOOLS,
806
- createServer,
1565
+ createServer2 as createServer,
807
1566
  defaultDir,
808
1567
  defaultName,
809
1568
  loadStored,
1569
+ recordClientCapabilities,
810
1570
  resolveSettings,
811
- run as runCli,
1571
+ run2 as runCli,
812
1572
  saveStored,
813
1573
  startNode,
814
1574
  unreadFooter