@openvole/volenet-mcp 0.1.0 → 0.2.1

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) });
381
+ }
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
+ }
403
+ }
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");
77
459
  }
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);
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
+ };
84
541
  }
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);
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);
101
587
  }
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);
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 {
113
647
  }
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);
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";
@@ -146,16 +718,17 @@ function addArgs(scope, command) {
146
718
  return ["mcp", "add", SERVER_NAME, "-s", 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
- function install(argv, out = process.stdout) {
150
- const scope = argv.includes("--user") ? "user" : "local";
721
+ var spawnClaude = (args) => spawnSync("claude", args, {
722
+ stdio: ["ignore", "pipe", "pipe"],
723
+ encoding: "utf-8",
724
+ timeout: 3e4
725
+ });
726
+ function install(argv, out = process.stdout, exec = spawnClaude) {
727
+ const scope = argv.includes("--local") ? "local" : "user";
151
728
  const command = launchCommand();
152
729
  const paste = `claude mcp add ${SERVER_NAME} -s ${scope} -- ${command.join(" ")}`;
153
- const run2 = (args) => spawnSync("claude", args, {
154
- stdio: ["ignore", "pipe", "pipe"],
155
- encoding: "utf-8",
156
- timeout: 3e4
157
- });
158
- const listed = run2(["mcp", "list"]);
730
+ const run3 = exec;
731
+ const listed = run3(["mcp", "list"]);
159
732
  if (listed.error) {
160
733
  out.write(
161
734
  `The \`claude\` CLI is not on PATH. Run this once, in the project you want it in:
@@ -166,12 +739,20 @@ function install(argv, out = process.stdout) {
166
739
  );
167
740
  return 1;
168
741
  }
169
- if (listed.stdout?.includes(`${SERVER_NAME}:`)) {
170
- out.write(`${SERVER_NAME} is already registered \u2014 nothing to do.
742
+ const existing = listed.stdout?.split("\n").find((l) => l.trimStart().startsWith(`${SERVER_NAME}:`));
743
+ if (existing) {
744
+ if (existing.includes(command.join(" "))) {
745
+ out.write(`${SERVER_NAME} is already registered, unchanged.
171
746
  ${NEXT_STEPS}`);
172
- return 0;
747
+ return 0;
748
+ }
749
+ out.write(`Replacing the existing ${SERVER_NAME} registration:
750
+ was: ${existing.trim()}
751
+ `);
752
+ run3(["mcp", "remove", SERVER_NAME, "-s", scope]);
753
+ run3(["mcp", "remove", SERVER_NAME, "-s", scope === "user" ? "local" : "user"]);
173
754
  }
174
- const added = run2(addArgs(scope, command));
755
+ const added = run3(addArgs(scope, command));
175
756
  if (added.status !== 0) {
176
757
  out.write(
177
758
  `Could not register it automatically${added.stderr ? `: ${added.stderr.trim()}` : ""}
@@ -184,25 +765,30 @@ Run this once instead:
184
765
  );
185
766
  return added.status ?? 1;
186
767
  }
187
- out.write(`Registered ${SERVER_NAME} (${scope} scope).
188
- ${NEXT_STEPS}`);
768
+ out.write(
769
+ `Registered ${SERVER_NAME} (${scope} scope${scope === "user" ? " \u2014 available in every project" : ", this project only"}).
770
+ ${NEXT_STEPS}`
771
+ );
189
772
  return 0;
190
773
  }
191
774
 
192
775
  // src/cli.ts
193
776
  var USAGE = `volenet-mcp \u2014 VoleNet as an MCP server
194
777
 
195
- volenet-mcp install [--user] register with Claude Code (default: this project)
778
+ volenet-mcp install [--local] register with Claude Code (default: every project)
196
779
  volenet-mcp whoami this machine's identity on the mesh
780
+ volenet-mcp daemon run the node in the foreground (normally started for you)
197
781
  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
782
+ volenet-mcp inbox [--read] [--quiet]
783
+ messages waiting. --read marks them seen, --quiet says
784
+ nothing when there are none (for hooks)
199
785
 
200
786
  With no command it runs as the MCP server itself, over stdio, which is how Claude Code starts it.
201
787
  Anything needing a live node \u2014 peers, pairing, asking an agent's brain \u2014 is a tool you ask for in
202
788
  a session, not a command here.
203
789
  `;
204
790
  var when = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 16);
205
- async function run(argv, out = process.stdout) {
791
+ async function run2(argv, out = process.stdout) {
206
792
  const [command, ...rest] = argv;
207
793
  const dir = defaultDir();
208
794
  if (!command || command === "help" || command === "--help" || command === "-h") {
@@ -210,9 +796,15 @@ async function run(argv, out = process.stdout) {
210
796
  return 0;
211
797
  }
212
798
  if (command === "install") return install(rest, out);
799
+ if (command === "daemon") {
800
+ const { runDaemon: runDaemon2 } = await Promise.resolve().then(() => (init_node(), node_exports));
801
+ const { resolveSettings: resolveSettings2 } = await Promise.resolve().then(() => (init_config(), config_exports));
802
+ await runDaemon2(await resolveSettings2());
803
+ return 0;
804
+ }
213
805
  if (command === "whoami") {
214
806
  const stored = await loadStored(dir);
215
- const keys = await loadKeyPair(path4.join(dir, "net")).catch(() => null);
807
+ const keys = await loadKeyPair(path6.join(dir, "net")).catch(() => null);
216
808
  if (!keys) {
217
809
  out.write(
218
810
  `No identity yet at ${dir}.
@@ -261,11 +853,11 @@ It is joined the next time the server starts \u2014 restart Claude Code, or ask
261
853
  return 0;
262
854
  }
263
855
  if (command === "inbox") {
264
- const inbox = new Inbox(path4.join(dir, "inbox.json"));
856
+ const inbox = new Inbox(dir, process.env.VOLENET_MCP_SESSION?.trim() || sessionKey());
265
857
  await inbox.load();
266
858
  const unread = inbox.unread();
267
859
  if (unread.length === 0) {
268
- out.write("No new messages.\n");
860
+ if (!rest.includes("--quiet")) out.write("No new messages.\n");
269
861
  return 0;
270
862
  }
271
863
  out.write(`${unread.length} new VoleNet message${unread.length === 1 ? "" : "s"}:
@@ -285,103 +877,149 @@ ${USAGE}`);
285
877
  return 1;
286
878
  }
287
879
 
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;
880
+ // src/index.ts
881
+ init_node();
882
+
883
+ // src/prompts.ts
884
+ var PROMPTS = [
885
+ {
886
+ name: "whoami",
887
+ description: "This session\u2019s identity on the VoleNet mesh, and whether it can reach anything.",
888
+ render: () => `Report this session's VoleNet identity.
889
+
890
+ Call \`volenet_whoami\`. Give back the name, the instance id and where it is listening, and say in
891
+ one line whether it is actually reachable \u2014 a hub joined, or peers paired \u2014 rather than leaving an
892
+ empty roster to be read as a failure.
893
+
894
+ The instance id is what someone else needs to grant this session anything: an agent's operator names
895
+ it in \`net.peers\`. Offer it if they look like they need it. The public key is several kilobytes of
896
+ post-quantum key material, so ask for it with \`key: true\` only when a peer actually wants it.
897
+
898
+ If nothing is connected, say so and offer \`setup\`.`
899
+ },
900
+ {
901
+ name: "peers",
902
+ description: "Who this session can reach right now, and by which route.",
903
+ render: () => `List who this session can reach on VoleNet.
904
+
905
+ Call \`volenet_peers\`. For each one say whether it is online, and whether the link is direct or
906
+ through a hub \u2014 the difference matters: a hub carries chat and consent, a direct link is the only
907
+ route that can ask an agent's brain.
908
+
909
+ Flag anything that needs an action rather than only listing state: a hub member with no consent yet
910
+ cannot be messaged until one side asks (\`volenet_connect\`), and someone offline will receive what
911
+ is sent whenever they return. If the list is empty, say why \u2014 no hub, no pairings \u2014 and offer
912
+ \`setup\`.`
913
+ },
914
+ {
915
+ name: "rooms",
916
+ description: "Rooms this session is in, and how to say something to one.",
917
+ render: () => `Show the VoleNet rooms this session is in.
918
+
919
+ Call \`volenet_room\` with no arguments. For each, say who is in it \u2014 a room is several people and
920
+ agents in one conversation, so who else is there is the useful part.
921
+
922
+ To say something, \`volenet_room\` with \`post\` and \`room\`. Every member gets their own sealed copy;
923
+ there is no shared key, which is why removing somebody stops them reading immediately. Report what
924
+ came back honestly: some copies may be waiting for members who are away, and some may not have been
925
+ sent at all because that member has not accepted this session \u2014 **a room does not create consent**,
926
+ so say that rather than let it read as a failure.
927
+
928
+ If there are no rooms, offer to make one (\`create\`) or to join one by id (\`join\`). A room lives on
929
+ a hub, so one has to be joined first.`
930
+ },
931
+ {
932
+ name: "setup",
933
+ description: "Get this session onto the VoleNet mesh \u2014 join a hub, or pair with an agent.",
934
+ render: () => `Get this session onto the VoleNet mesh.
935
+
936
+ 1. Call \`volenet_whoami\` first. It reports the identity, whether a hub is set, and how many peers
937
+ are reachable. An identity is generated on first run; there is nothing to create.
938
+ 2. If nothing is connected, explain the two routes and ask which is wanted \u2014 do not pick silently:
939
+ - **A hub** (\`volenet_hub\`) makes this session reachable from anywhere, including from a phone,
940
+ and works when neither side can dial the other. A hub carries sealed traffic it cannot read and
941
+ stores no message. It will **not** relay a question to an agent's brain.
942
+ - **A direct pair** (\`volenet_connect\`) with an agent whose address is reachable from here. This
943
+ is the only route that can ask an agent's brain.
944
+ Both can be used at once, and either can be added later.
945
+ 3. Carry out whichever they choose. For a hub, the URL is enough. For a pair, follow the two-step
946
+ fingerprint check \u2014 the \`pair\` command covers it.
947
+ 4. Finish by calling \`volenet_peers\` and saying plainly who is now reachable, and by which route.
948
+
949
+ Reaching someone also needs consent, which is separate from being connected: on a hub, either side
950
+ asks and the other accepts. Say so, rather than letting an empty roster look like a failure.`
951
+ },
952
+ {
953
+ name: "catch-up",
954
+ description: "Read what arrived while this session was away, and say what needs answering.",
955
+ render: () => `Catch up on VoleNet.
956
+
957
+ 1. Call \`volenet_inbox\`. It returns messages that arrived \u2014 including while no session was running,
958
+ since senders hold what they could not deliver and flush on reconnect \u2014 and who tried to reach
959
+ this session while it was away. Reading marks them seen.
960
+ 2. Call \`volenet_peers\` if anything needs context about who a sender is.
961
+ 3. Summarise for the person: who wrote, what they want, and what is worth answering. Do not reply on
962
+ their behalf without asking.
963
+ 4. If a reply is wanted, \`volenet_send\` says it and \`volenet_wait\` waits for what comes back, so
964
+ an exchange happens in one turn rather than by checking again later.
965
+
966
+ If nothing arrived, say so in one line. This is worth running at the start of a session.`
967
+ },
968
+ {
969
+ name: "pair",
970
+ description: "Pair with an agent at a URL, checking the fingerprint before trusting it.",
971
+ arguments: [
972
+ {
973
+ name: "url",
974
+ description: "The agent to pair with, e.g. http://10.0.0.5:9700",
975
+ required: true
976
+ }
977
+ ],
978
+ render: (a) => `Pair this session with the VoleNet node at ${a.url ?? "<url>"}.
979
+
980
+ Pairing is deliberately two calls, because trusting a URL blind is trusting whoever holds it.
981
+
982
+ 1. Call \`volenet_connect\` with \`url: "${a.url ?? "<url>"}"\`. It reaches the node and reports the
983
+ fingerprint of whoever answered. It trusts nothing yet.
984
+ 2. Show that fingerprint to the person and ask them to check it against what the other side reports
985
+ \u2014 \`vole net show-key\` on an OpenVole agent. **Wait for them.** Do not confirm on their behalf:
986
+ this step exists precisely so a human compares two values.
987
+ 3. Ask whether this session should also be able to use that agent's **brain** \u2014 running its model
988
+ to answer questions \u2014 or only chat with whoever runs it.
989
+ 4. Once they confirm the fingerprint, call \`volenet_connect\` again with the same \`url\`,
990
+ \`confirm:\` set to that fingerprint, and \`brain: true\` if they said yes. This trusts the node
991
+ and sends a pair request carrying the ask.
992
+ 5. Tell them the request now waits for the operator of that node to accept it, and that nothing
993
+ arrives until they do.
994
+
995
+ Being trusted is not the same as being allowed to do anything: the keystore says who may connect,
996
+ \`net.peers\` says what they may then do. Sending the ask with the request is what lets the operator
997
+ settle both while accepting, instead of editing a config file afterwards.`
998
+ },
999
+ {
1000
+ name: "reach",
1001
+ description: "Message a peer and wait for the reply, rather than checking back later.",
1002
+ arguments: [
1003
+ { name: "peer", description: "Who to reach \u2014 a name or instance id", required: true },
1004
+ { name: "message", description: "What to say", required: false }
1005
+ ],
1006
+ render: (a) => `Reach ${a.peer ?? "a peer"} over VoleNet${a.message ? ` and say: ${a.message}` : ""}.
1007
+
1008
+ 1. \`volenet_peers\` first if unsure the name resolves, or by which route they are reachable.
1009
+ 2. \`volenet_send\` to say it. This is chat: it reaches whoever is there and does **not** run their
1010
+ brain. To ask an agent's model instead, use \`volenet_ask\` \u2014 direct links only, and its operator
1011
+ must have granted brain access.
1012
+ 3. \`volenet_wait\` for the answer, so the exchange completes in this turn. If nothing comes back in
1013
+ time, say so plainly: the message is not lost, and a reply lands in the inbox whenever it comes.
1014
+
1015
+ If the peer is offline the message waits here and goes out when they return \u2014 report that rather
1016
+ than treating it as a failure.`
381
1017
  }
382
- }
1018
+ ];
383
1019
 
384
1020
  // src/tools.ts
1021
+ init_config();
1022
+ init_node();
385
1023
  var obj = (properties, required = []) => ({
386
1024
  type: "object",
387
1025
  properties,
@@ -390,18 +1028,15 @@ var obj = (properties, required = []) => ({
390
1028
  var str = (description) => ({ type: "string", description });
391
1029
  var num = (description) => ({ type: "number", description });
392
1030
  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) => ({
1031
+ async function peers(node) {
1032
+ const out = (await node.net.instances()).map((i) => ({
398
1033
  id: i.id,
399
1034
  name: i.name,
400
1035
  route: "direct",
401
- connected: live.has(i.id)
1036
+ connected: i.connected
402
1037
  }));
403
1038
  const direct = new Set(out.map((p) => p.id));
404
- for (const m of node.net.getRelayMembers()) {
1039
+ for (const m of await node.net.relayMembers()) {
405
1040
  if (direct.has(m.id)) continue;
406
1041
  out.push({
407
1042
  id: m.id,
@@ -414,8 +1049,8 @@ function peers(node) {
414
1049
  }
415
1050
  return out;
416
1051
  }
417
- function resolve(node, ref) {
418
- const all = peers(node);
1052
+ async function resolve(node, ref) {
1053
+ const all = await peers(node);
419
1054
  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
1055
  }
421
1056
  var TOOLS = [
@@ -426,16 +1061,20 @@ var TOOLS = [
426
1061
  key: { type: "boolean", description: "Include the full public key string" }
427
1062
  }),
428
1063
  async run(node, args) {
429
- const key = node.net.getKeyPair();
430
- const online = peers(node).filter((p) => p.connected).length;
1064
+ const key = await node.net.identity();
1065
+ const online = (await peers(node)).filter((p) => p.connected).length;
431
1066
  const lines = [
432
1067
  `name ${node.options.name}`,
433
1068
  `instanceId ${key?.instanceId ?? "(not started)"}`,
434
1069
  `hub ${node.hubStatus}`,
435
1070
  `connected ${online} peer(s) online`,
436
1071
  `listening port ${node.options.port} (reachable only from networks that can dial it)`,
437
- `store ${node.options.dir}`
1072
+ `store ${node.options.dir}`,
1073
+ `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
1074
  ];
1075
+ lines.push(
1076
+ `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"}`
1077
+ );
439
1078
  if (args.key) lines.push("", `publicKey ${key?.publicKeyString ?? "-"}`);
440
1079
  else
441
1080
  lines.push(
@@ -455,7 +1094,7 @@ var TOOLS = [
455
1094
  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
1095
  inputSchema: obj({}),
457
1096
  async run(node) {
458
- const all = peers(node);
1097
+ const all = await peers(node);
459
1098
  if (all.length === 0) {
460
1099
  return "No peers. Join a hub (VOLENET_MCP_HUB) or pair with a node directly (volenet_pair).";
461
1100
  }
@@ -512,7 +1151,7 @@ var TOOLS = [
512
1151
  const to = String(args.to ?? "");
513
1152
  const text = String(args.text ?? "");
514
1153
  if (!text.trim()) return "Nothing to send.";
515
- const peer = resolve(node, to);
1154
+ const peer = await resolve(node, to);
516
1155
  const res = await node.net.sendChat(peer?.id ?? to, text);
517
1156
  if (!res.ok) return `Not sent: ${res.error ?? "unknown error"}`;
518
1157
  await node.inbox.add({
@@ -537,7 +1176,7 @@ var TOOLS = [
537
1176
  limit: num("Messages (default 50)")
538
1177
  }),
539
1178
  async run(node, args) {
540
- const peer = resolve(node, String(args.peer ?? ""));
1179
+ const peer = await resolve(node, String(args.peer ?? ""));
541
1180
  const id = peer?.id ?? String(args.peer ?? "");
542
1181
  const msgs = node.inbox.history(id, Number(args.limit ?? 50));
543
1182
  if (msgs.length === 0) return `Nothing recorded with ${peer?.name ?? id}.`;
@@ -556,16 +1195,15 @@ var TOOLS = [
556
1195
  ["to", "question"]
557
1196
  ),
558
1197
  async run(node, args) {
559
- const peer = resolve(node, String(args.to ?? ""));
1198
+ const peer = await resolve(node, String(args.to ?? ""));
560
1199
  if (!peer) return `No peer found: "${args.to}". Use volenet_peers.`;
561
1200
  if (peer.route !== "direct") {
562
1201
  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
1202
  }
564
- const mgr = node.net.getRemoteTaskManager();
565
- if (!mgr) return "Remote task manager not available.";
566
- const res = await mgr.delegateTask(
1203
+ const res = await node.net.askBrain(
567
1204
  peer.id,
568
- { taskId: "", input: String(args.question ?? ""), fromName: node.options.name },
1205
+ String(args.question ?? ""),
1206
+ node.options.name,
569
1207
  Number(args.timeout_ms ?? 12e4)
570
1208
  );
571
1209
  if (res.status === "completed") return `${peer.name} says:
@@ -575,7 +1213,7 @@ ${res.result}`;
575
1213
  if (typeof why === "string" && why.includes("allowBrain")) {
576
1214
  return `${peer.name} refused: ${why}
577
1215
 
578
- Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.instanceId}", "trust": "read", "allowBrain": true } to net.peers and restarting.`;
1216
+ Its operator can allow this session by adding { "id": "${(await node.net.identity())?.instanceId}", "trust": "read", "allowBrain": true } to net.peers and restarting.`;
579
1217
  }
580
1218
  return `${peer.name} did not answer: ${why}`;
581
1219
  }
@@ -598,7 +1236,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
598
1236
  node.requests.splice(node.requests.indexOf(req), 1);
599
1237
  return ok.ok ? `${accept ? "Accepted" : "Denied"} ${req.fromName}.` : `Failed: ${"error" in ok ? ok.error : "unknown"}`;
600
1238
  }
601
- const pairs = node.net.listPairRequests();
1239
+ const pairs = await node.net.listPairRequests();
602
1240
  for (const p of pairs) {
603
1241
  if (!node.requests.some((r) => r.from === p.id)) {
604
1242
  node.requests.push({ kind: "pair", from: p.id, fromName: p.name, at: Date.now() });
@@ -619,7 +1257,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
619
1257
  }),
620
1258
  async run(node, args) {
621
1259
  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;
1260
+ const want = args.from ? await resolve(node, String(args.from)) : void 0;
623
1261
  const wanted = (m) => !args.from || m.peerId === (want?.id ?? args.from);
624
1262
  const already = node.inbox.unread().filter(wanted);
625
1263
  if (already.length === 0) {
@@ -655,7 +1293,7 @@ Its operator can allow this session by adding { "id": "${node.net.getKeyPair()?.
655
1293
  if (args.leave) {
656
1294
  const hub = node.options.hub;
657
1295
  if (!hub) return "Not on a hub.";
658
- node.net.forgetPeer(hub);
1296
+ await node.net.forgetPeer(hub);
659
1297
  node.options.hub = void 0;
660
1298
  node.hubStatus = "no hub configured";
661
1299
  await saveStored(node.options.dir, { hub: void 0 });
@@ -674,7 +1312,7 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
674
1312
  await saveStored(node.options.dir, { hub: url });
675
1313
  return `Joined ${url} again \u2014 it was already trusted, so no introduction was needed.`;
676
1314
  }
677
- const res = await node.net.initiateJoin(url);
1315
+ const res = await node.net.joinHub(url);
678
1316
  if (!res.ok) return `Could not join ${url}: ${res.error}`;
679
1317
  node.options.hub = url;
680
1318
  node.hubStatus = res.pending ? `waiting for approval at ${url}` : `joined ${res.hubName ?? url}`;
@@ -682,12 +1320,84 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
682
1320
  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
1321
  }
684
1322
  },
1323
+ {
1324
+ name: "volenet_room",
1325
+ 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.",
1326
+ inputSchema: obj({
1327
+ room: str("Which room, by id or name (see the list)"),
1328
+ post: str("Say this to the room"),
1329
+ create: str("Make a room with this name"),
1330
+ join: str("Join a room by id"),
1331
+ leave: { type: "boolean", description: "Leave the room named in `room`" },
1332
+ invite: str("Bring this peer (name or id) into the room named in `room`"),
1333
+ hub: str("Which hub holds the room. Only needed with more than one.")
1334
+ }),
1335
+ async run(node, args) {
1336
+ const hubOf = async () => {
1337
+ if (args.hub) return String(args.hub);
1338
+ const hubs = (await node.net.instances()).filter((i) => i.connected);
1339
+ if (hubs.length === 0) return null;
1340
+ return hubs[0].id;
1341
+ };
1342
+ const find = async (ref) => {
1343
+ const all2 = await node.net.rooms();
1344
+ return all2.find((r) => r.room === ref) ?? all2.find((r) => r.name === ref);
1345
+ };
1346
+ if (args.create || args.join || args.leave || args.invite) {
1347
+ const hub = await hubOf();
1348
+ if (!hub)
1349
+ return "No hub connected. A room lives on a hub \u2014 join one first with volenet_hub.";
1350
+ if (args.create) {
1351
+ const res2 = await node.net.roomCommand(hub, "room:create", { name: String(args.create) });
1352
+ return res2.ok ? `Asked for a room called "${args.create}". Call this again in a moment to see it.` : `Could not: ${res2.error}`;
1353
+ }
1354
+ if (args.join) {
1355
+ const res2 = await node.net.roomCommand(hub, "room:join", { room: String(args.join) });
1356
+ return res2.ok ? `Asked to join ${args.join}.` : `Could not: ${res2.error}`;
1357
+ }
1358
+ const room = args.room ? await find(String(args.room)) : void 0;
1359
+ if (!room) return "Name the room with `room` \u2014 see the list.";
1360
+ if (args.leave) {
1361
+ const res2 = await node.net.roomCommand(hub, "room:leave", { room: room.room });
1362
+ return res2.ok ? `Left ${room.name}.` : `Could not: ${res2.error}`;
1363
+ }
1364
+ const peer = await resolve(node, String(args.invite));
1365
+ const res = await node.net.roomCommand(hub, "room:invite", {
1366
+ room: room.room,
1367
+ member: peer?.id ?? String(args.invite)
1368
+ });
1369
+ return res.ok ? `Invited ${peer?.name ?? args.invite} to ${room.name}.` : `Could not: ${res.error}`;
1370
+ }
1371
+ if (args.post) {
1372
+ const room = args.room ? await find(String(args.room)) : (await node.net.rooms())[0];
1373
+ if (!room) return "No room to post to. Create or join one first.";
1374
+ const res = await node.net.postToRoom(room.room, String(args.post));
1375
+ if (!res.ok) return `Not posted: ${res.error}`;
1376
+ const bits = [`Posted to ${room.name}: ${res.sent} delivered`];
1377
+ if (res.held) bits.push(`${res.held} waiting for members who are away`);
1378
+ if (res.skipped)
1379
+ bits.push(`${res.skipped} could not be reached \u2014 they may not have accepted you`);
1380
+ return `${bits.join(", ")}.`;
1381
+ }
1382
+ const all = await node.net.rooms();
1383
+ if (all.length === 0) {
1384
+ return 'Not in any room. Create one with create:"name", or join one you have been given the id for.';
1385
+ }
1386
+ return all.map(
1387
+ (r) => ` ${r.name} ${r.room.substring(0, 8)} ${r.members.length} member(s): ${r.members.map((m) => m.name).join(", ")}`
1388
+ ).join("\n");
1389
+ }
1390
+ },
685
1391
  {
686
1392
  name: "volenet_connect",
687
1393
  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
1394
  inputSchema: obj({
689
1395
  url: str("Node URL to pair with directly, e.g. http://10.0.0.5:9700"),
690
1396
  confirm: str("The fingerprint returned by a first call with url, confirming who answers"),
1397
+ brain: {
1398
+ type: "boolean",
1399
+ 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."
1400
+ },
691
1401
  member: str("Hub member name or id to ask for chat consent"),
692
1402
  note: str("A line saying who you are")
693
1403
  }),
@@ -711,8 +1421,18 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
711
1421
  if (!probe.fingerprint?.startsWith(confirm)) {
712
1422
  return `That fingerprint does not match: it answers with ${probe.fingerprint}. Nothing was trusted.`;
713
1423
  }
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}`;
1424
+ const res = await node.net.initiatePair(
1425
+ url,
1426
+ probe.publicKey,
1427
+ note,
1428
+ args.brain ? ["brain"] : void 0
1429
+ );
1430
+ if (!res.ok) return `Could not ask: ${res.error}`;
1431
+ return [
1432
+ `Trusted ${probe.name ?? url} and asked it to trust this session.`,
1433
+ 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.",
1434
+ "Nothing arrives until their operator accepts."
1435
+ ].join(" ");
716
1436
  }
717
1437
  if (args.member) {
718
1438
  const res = await node.net.requestRelayConnect(String(args.member), note);
@@ -725,6 +1445,9 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
725
1445
  ];
726
1446
 
727
1447
  // src/index.ts
1448
+ init_inbox();
1449
+ init_config();
1450
+ init_node();
728
1451
  function unreadFooter(node, toolName) {
729
1452
  if (toolName === "volenet_inbox" || toolName === "volenet_wait") return "";
730
1453
  const unread = node.inbox.unread();
@@ -734,8 +1457,34 @@ function unreadFooter(node, toolName) {
734
1457
 
735
1458
  \u2014 ${unread.length} unread message${unread.length === 1 ? "" : "s"} from ${who}. Read them with volenet_inbox.`;
736
1459
  }
737
- function createServer(node) {
738
- const server = new Server({ name: "volenet", version: "0.1.0" }, { capabilities: { tools: {} } });
1460
+ function createServer2(node) {
1461
+ const server = new Server(
1462
+ { name: "volenet", version: "0.1.0" },
1463
+ { capabilities: { tools: {}, prompts: {} } }
1464
+ );
1465
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
1466
+ prompts: PROMPTS.map((p) => ({
1467
+ name: p.name,
1468
+ description: p.description,
1469
+ ...p.arguments ? { arguments: p.arguments } : {}
1470
+ }))
1471
+ }));
1472
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
1473
+ const prompt = PROMPTS.find((p) => p.name === request.params.name);
1474
+ if (!prompt) throw new Error(`No such prompt: ${request.params.name}`);
1475
+ return {
1476
+ description: prompt.description,
1477
+ messages: [
1478
+ {
1479
+ role: "user",
1480
+ content: {
1481
+ type: "text",
1482
+ text: prompt.render(request.params.arguments ?? {})
1483
+ }
1484
+ }
1485
+ ]
1486
+ };
1487
+ });
739
1488
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
740
1489
  tools: TOOLS.map((t) => ({
741
1490
  name: t.name,
@@ -765,13 +1514,31 @@ function createServer(node) {
765
1514
  });
766
1515
  return server;
767
1516
  }
1517
+ async function recordClientCapabilities(dir, caps) {
1518
+ try {
1519
+ const fs4 = await import("fs/promises");
1520
+ const path7 = await import("path");
1521
+ await fs4.mkdir(dir, { recursive: true });
1522
+ await fs4.writeFile(
1523
+ path7.join(dir, "client.json"),
1524
+ `${JSON.stringify(caps ?? {}, null, 2)}
1525
+ `,
1526
+ "utf-8"
1527
+ );
1528
+ } catch {
1529
+ }
1530
+ }
768
1531
  async function main() {
769
1532
  const options = await resolveSettings();
770
1533
  const node = await startNode(options);
771
- const server = createServer(node);
1534
+ const server = createServer2(node);
772
1535
  await server.connect(new StdioServerTransport());
1536
+ const caps = server.getClientCapabilities();
1537
+ node.canSample = Boolean(caps && typeof caps === "object" && "sampling" in caps);
1538
+ await recordClientCapabilities(options.dir, caps);
1539
+ const me = await node.net.identity().catch(() => null);
773
1540
  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"}
1541
+ `volenet-mcp: ${options.name} (${me?.instanceId.substring(0, 8) ?? "?"}) ready \u2014 node ${node.where}${options.hub ? `, hub ${options.hub}` : ", no hub configured"}
775
1542
  `
776
1543
  );
777
1544
  let stopping = false;
@@ -787,7 +1554,7 @@ async function main() {
787
1554
  }
788
1555
  if (process.argv[1]?.includes("volenet-mcp") || process.env.VOLENET_MCP_RUN === "1") {
789
1556
  if (process.argv[2] || process.stdin.isTTY) {
790
- run(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1557
+ run2(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
791
1558
  process.stderr.write(`volenet-mcp: ${err instanceof Error ? err.message : String(err)}
792
1559
  `);
793
1560
  process.exit(1);
@@ -802,13 +1569,15 @@ if (process.argv[1]?.includes("volenet-mcp") || process.env.VOLENET_MCP_RUN ===
802
1569
  }
803
1570
  export {
804
1571
  Inbox,
1572
+ PROMPTS,
805
1573
  TOOLS,
806
- createServer,
1574
+ createServer2 as createServer,
807
1575
  defaultDir,
808
1576
  defaultName,
809
1577
  loadStored,
1578
+ recordClientCapabilities,
810
1579
  resolveSettings,
811
- run as runCli,
1580
+ run2 as runCli,
812
1581
  saveStored,
813
1582
  startNode,
814
1583
  unreadFooter