@openvole/volenet-mcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -17,8 +17,8 @@ npx -y @openvole/volenet-mcp install
17
17
  ```
18
18
 
19
19
  That is the whole setup. It registers the server with Claude Code, and there is nothing to
20
- configure: an identity is generated on first run, the name defaults to `claude-<hostname>`, and
21
- whether to join a hub is a decision you make later, from inside a session.
20
+ configure: an identity is generated on first run for whichever project you are in, named after it,
21
+ and whether to join a hub is a decision you make later, from inside a session.
22
22
 
23
23
  Restart Claude Code afterwards — MCP servers load at startup.
24
24
 
@@ -32,27 +32,33 @@ your behalf, not commands you type. Ask for them in your own words.
32
32
  > *"pair with the agent at http://10.0.0.5:9700"* → `volenet_connect`, which reports the
33
33
  > fingerprint first and pairs once you confirm it
34
34
 
35
- It registers for **every project**, because the identity it installs is per machine one keypair
36
- in your home directory, shared by every session. Add `--local` to limit it to the current project.
35
+ Running it again is safe, and is how you move an existing registration: an identical one is left
36
+ alone, and one pointing somewhere else a working-tree build, say, when you have since installed
37
+ the published package — is replaced rather than reported as already done.
38
+
39
+ It registers for **every project** — the server is the same everywhere, and it gives each project
40
+ its own identity when it starts there. Add `--local` to register it in the current project only.
37
41
  If the `claude` CLI is not on PATH, the installer prints the one line to paste instead of guessing
38
42
  at its config.
39
43
 
40
44
  ### Settings
41
45
 
42
- Settings live with the identity in `~/.openvole/volenet-mcp/`, not in the command that launches
43
- the server, so changing one never means re-registering anything. `volenet_hub` writes the hub
44
- there; everything else has a default worth keeping.
46
+ Settings live with the identity, in that project's directory, not in the command that launches the
47
+ server so changing one never means re-registering anything. `volenet_hub` writes the hub there;
48
+ everything else has a default worth keeping.
45
49
 
46
50
  | variable | default | what |
47
51
  |---|---|---|
48
- | `VOLENET_MCP_NAME` | `claude-<hostname>` | What peers see. Not identity — the key is. |
52
+ | `VOLENET_MCP_NAME` | `claude-<project>` | What peers see. Not identity — the key is. |
49
53
  | `VOLENET_MCP_HUB` | none | Normally set by `volenet_hub`; this overrides it for scripted setups. |
50
- | `VOLENET_MCP_DIR` | `~/.openvole/volenet-mcp` | Keypair, trust store, settings, transcript. |
51
- | `VOLENET_MCP_PORT` | `9750` | Listening port, for peers that *can* dial you. |
54
+ | `VOLENET_MCP_DIR` | `~/.openvole/volenet-mcp/<project>` | Keypair, trust store, settings, messages. Set it to share one identity between projects. |
55
+ | `VOLENET_MCP_PORT` | `0` | Listening port. A session dials out, so it takes any free one; set it only if a peer must dial you. |
56
+ | `VOLENET_MCP_SESSION` | `session` | Which read state to use. Set it to keep two readers in one project apart. |
57
+ | `VOLENET_MCP_NOTIFY` | platform default | `off`, or a command run with the title and body. |
52
58
 
53
59
  Environment wins over stored settings, which win over defaults. None of it is required.
54
60
 
55
- That directory **is** your identity: back it up, and anyone who has it is you.
61
+ That directory **is** that project's identity: back it up, and anyone who has it is you.
56
62
 
57
63
  ## Slash commands
58
64
 
@@ -61,7 +67,7 @@ rather than hoping a sentence matches the right tool.
61
67
 
62
68
  | command | what it does |
63
69
  |---|---|
64
- | `whoami` | this session's identity, and whether it can reach anything |
70
+ | `whoami` | this project's identity, and whether it can reach anything |
65
71
  | `peers` | who is reachable, and by which route |
66
72
  | `setup` | get onto the mesh — join a hub, or pair with an agent |
67
73
  | `pair` | pair with an agent, fingerprint checked, asking for brain access if wanted |
@@ -76,11 +82,13 @@ files only — no node is started, no port is bound — so they are safe to run
76
82
  and safe in a hook that fires on every session.
77
83
 
78
84
  ```bash
79
- volenet-mcp install [--user] register with Claude Code
80
- volenet-mcp whoami this machine's identity on the mesh
85
+ volenet-mcp install [--local] register with Claude Code (default: every project)
86
+ volenet-mcp adopt claim an identity left at the old shared location
87
+ volenet-mcp whoami this project's identity on the mesh
81
88
  volenet-mcp hub <url> set the hub; joined on the next session start
82
89
  volenet-mcp hub --leave come off it
83
90
  volenet-mcp inbox [--read] what is waiting
91
+ volenet-mcp wait [--timeout s] block until a message arrives, then print it and exit
84
92
  ```
85
93
 
86
94
  Anything needing a live node — the roster, pairing, asking an agent's brain — is a tool rather
@@ -136,6 +144,12 @@ that command is run with the title and body as its two arguments.
136
144
  Claude Code declares no capabilities at all — `volenet_whoami` reports which it is, so nobody waits
137
145
  for a reply that cannot come. What is left is making sure an arrived message is *seen* promptly.
138
146
 
147
+ **A session can be reached unprompted**, though not by this server. MCP gives a server no way to
148
+ wake a client — but a process that *exits* does. `volenet-mcp wait` blocks on the message log and
149
+ exits when something lands, so running it in the background makes an arriving message wake the
150
+ session that started it. One arrival per wait, so re-arm after each; and it only helps while a
151
+ session is open.
152
+
139
153
  For catch-up at the start of a session, ask for the inbox — or have it arrive before you type
140
154
  anything, with a `SessionStart` hook in `.claude/settings.json`:
141
155
 
@@ -151,8 +165,8 @@ anything, with a `SessionStart` hook in `.claude/settings.json`:
151
165
 
152
166
  `--read` marks them seen, since the hook has just put them in front of you.
153
167
 
154
- Swap `SessionStart` for `UserPromptSubmit` and waiting messages arrive on every turn you take,
155
- which is as close to unprompted as this client allows.
168
+ Swap `SessionStart` for `UserPromptSubmit` and waiting messages arrive on every turn you take;
169
+ add `PostToolUse` and they arrive within a tool call while the session is working.
156
170
 
157
171
  ## The node runs in a daemon
158
172
 
@@ -173,20 +187,36 @@ and keeps its own cursor — the protocol covers acting, not looking, and your h
173
187
  if the daemon is gone. `VOLENET_MCP_NO_DAEMON=1` keeps the node in the session, which is the
174
188
  fallback where spawning is not allowed.
175
189
 
176
- ## One identity, several sessions
190
+ ## One identity per project
191
+
192
+ The identity is the **project directory**, not the machine. Pairing is per identity, so a shared
193
+ one makes every session the same participant: the peer you paired with cannot tell them apart, and
194
+ each reads the others' conversations. Two projects open at once are two correspondents and look
195
+ like it — separate keys, separate pairings, separate history.
196
+
197
+ Several sessions in the *same* directory are one participant, which is right: same project, same
198
+ conversation, same history.
199
+
200
+ ```
201
+ ~/.openvole/volenet-mcp/
202
+ my-project-8c7bd921/ keys, peers, settings, messages
203
+ other-thing-9d0f8056/ a different peer entirely
204
+ ```
205
+
206
+ The name a peer sees follows the project too — `claude-my-project` — which says something useful to
207
+ whoever is on the other end, and keeps your machine's hostname off their roster.
177
208
 
178
- The identity is per machine one keypair in `~/.openvole/volenet-mcp/`, shared by every session,
179
- because pairing once is the whole point of having one. Being *caught up* is not shared: messages
180
- are an append-only log, and each session keeps its own read cursor, keyed by the directory it was
181
- started in.
209
+ A node dials the hub and everything it has paired with, both recorded beside the identity. Trust
210
+ and address are different things kept in different places: the keystore says whose signature to
211
+ accept, this says where to find them. Without the second, a paired peer stays trusted and
212
+ unreachable after a restart.
182
213
 
183
- So a session opening its inbox does not mark those messages seen for the others; a session
184
- reopened in the same project is the same reader and does not replay what it has already been
185
- shown; and two projects open at once are two readers of one log. Appending rather than rewriting
186
- is what makes that safe without a lock or a daemon.
214
+ `VOLENET_MCP_DIR` overrides the directory outright, which is how you deliberately share one
215
+ identity between projects. That is the exception, not the default.
187
216
 
188
- `VOLENET_MCP_SESSION` names the reader explicitly, if you want two sessions in one directory kept
189
- apart.
217
+ Upgrading from the version that kept one identity for the whole machine: `volenet-mcp adopt` claims
218
+ it for the current directory — keys, peers and history intact, so nothing needs re-pairing. Only one
219
+ project can have it, so which one is a decision rather than a guess.
190
220
 
191
221
  ## Session lifetime
192
222
 
package/dist/index.d.ts CHANGED
@@ -4,18 +4,44 @@ interface StoredConfig {
4
4
  name?: string;
5
5
  hub?: string;
6
6
  port?: number;
7
+ /**
8
+ * Nodes to dial on start, learned by pairing.
9
+ *
10
+ * Trust and address are different things and are kept in different places: the keystore says
11
+ * whose signature to accept, and this says where to find them. Without it a paired peer stayed
12
+ * trusted and unreachable after a restart — nothing dialled it, so nothing connected.
13
+ */
14
+ peers?: string[];
7
15
  }
16
+ /** Add a peer URL to what this identity dials, keeping the list unique and bounded. */
17
+ declare function rememberPeer(dir: string, url: string): Promise<void>;
8
18
  interface Settings {
9
19
  name: string;
10
20
  hub?: string;
11
21
  dir: string;
12
22
  port: number;
13
- /** Which read state in the shared inbox is this session's. See {@link sessionKey}. */
23
+ /** Which read state in this directory's inbox is ours. See {@link sessionKey}. */
14
24
  session: string;
15
25
  }
16
- declare function defaultDir(): string;
17
- /** A name that says what this is without needing to be chosen. Identity is the key, not this. */
18
- declare function defaultName(): string;
26
+ /** Where every identity on this machine is kept, one directory each. */
27
+ declare function baseDir(): string;
28
+ /**
29
+ * A stable, recognisable name for a directory: its basename, plus a hash so two projects that
30
+ * share one do not share an identity.
31
+ */
32
+ declare function sessionKey(cwd?: string): string;
33
+ /**
34
+ * This project's identity directory.
35
+ *
36
+ * `VOLENET_MCP_DIR` overrides it outright, which is how you deliberately share one identity
37
+ * between projects — the exception, not the default.
38
+ */
39
+ declare function defaultDir(cwd?: string): string;
40
+ /**
41
+ * What peers see. The project's name, not the machine's — it says something useful to whoever is
42
+ * on the other end, and it keeps a laptop's hostname off other people's rosters.
43
+ */
44
+ declare function defaultName(cwd?: string): string;
19
45
  declare function loadStored(dir: string): Promise<StoredConfig>;
20
46
  declare function saveStored(dir: string, patch: StoredConfig): Promise<StoredConfig>;
21
47
  /** Environment over stored settings over defaults. Every layer is optional. */
@@ -49,8 +75,19 @@ declare class Inbox {
49
75
  private get log();
50
76
  private get cursor();
51
77
  load(): Promise<void>;
52
- /** Re-read what other sessions have appended since we loaded. */
78
+ /**
79
+ * Re-read what has changed on disk since we loaded — both the messages another session
80
+ * appended, and the cursor another *process* moved.
81
+ *
82
+ * The cursor matters as much as the messages: a long-lived server holds one in memory while a
83
+ * hook, running as its own process for the same session, marks things read behind it. Without
84
+ * this the server goes on reporting messages as unread that the person has already been shown.
85
+ */
53
86
  refresh(): Promise<void>;
87
+ /** Take the later of what we hold and what is on disk: a cursor only ever moves forward. */
88
+ private readCursor;
89
+ /** Cursors only move forward, so take the later of what we hold and what was read. */
90
+ private merge;
54
91
  /** Record a message. Returns false when this id was already recorded. */
55
92
  add(m: Message): Promise<boolean>;
56
93
  /** Everything with one peer, oldest first. */
@@ -132,6 +169,12 @@ interface AskResult {
132
169
  interface Identity {
133
170
  instanceId: string;
134
171
  publicKeyString: string;
172
+ /**
173
+ * The port the node actually bound, which is the daemon's business and not the session's.
174
+ * A session attached to a daemon has no listener of its own, so reporting its own configured
175
+ * port would name one nothing is listening on.
176
+ */
177
+ port?: number;
135
178
  }
136
179
  interface RoomView {
137
180
  room: string;
@@ -169,6 +212,7 @@ interface NetLike {
169
212
  initiatePair(url: string, publicKey: string, note?: string, wants?: string[]): Promise<{
170
213
  ok: boolean;
171
214
  pending?: boolean;
215
+ alreadyTrusted?: boolean;
172
216
  error?: string;
173
217
  }>;
174
218
  requestRelayConnect(ref: string, note?: string): Promise<{
@@ -314,10 +358,10 @@ declare const TOOLS: ToolDef[];
314
358
  * ambient awareness in place of the notification the protocol cannot send. The tools that just
315
359
  * showed you the messages are excluded, since they leave nothing unread.
316
360
  */
317
- declare function unreadFooter(node: Node, toolName: string): string;
361
+ declare function unreadFooter(node: Node, toolName: string): Promise<string>;
318
362
  /** Wire the tools to an MCP server. Separated so a test can drive it without a transport. */
319
363
  declare function createServer(node: Node): Server;
320
364
  /** Remember what the client can do, so a later session can say so without asking again. */
321
365
  declare function recordClientCapabilities(dir: string, caps: unknown): Promise<void>;
322
366
 
323
- export { Inbox, type Node, type NodeOptions, PROMPTS, type PromptDef, type Settings, TOOLS, type ToolDef, createServer, defaultDir, defaultName, loadStored, recordClientCapabilities, resolveSettings, run as runCli, saveStored, startNode, unreadFooter };
367
+ export { Inbox, type Node, type NodeOptions, PROMPTS, type PromptDef, type Settings, type StoredConfig, TOOLS, type ToolDef, baseDir, createServer, defaultDir, defaultName, loadStored, recordClientCapabilities, rememberPeer, resolveSettings, run as runCli, saveStored, sessionKey, startNode, unreadFooter };
package/dist/index.js CHANGED
@@ -17,9 +17,12 @@ var __export = (target, all) => {
17
17
  // src/config.ts
18
18
  var config_exports = {};
19
19
  __export(config_exports, {
20
+ baseDir: () => baseDir,
21
+ cursorKey: () => cursorKey,
20
22
  defaultDir: () => defaultDir,
21
23
  defaultName: () => defaultName,
22
24
  loadStored: () => loadStored,
25
+ rememberPeer: () => rememberPeer,
23
26
  resolveSettings: () => resolveSettings,
24
27
  saveStored: () => saveStored,
25
28
  sessionKey: () => sessionKey
@@ -28,16 +31,31 @@ import * as crypto from "crypto";
28
31
  import * as fs from "fs/promises";
29
32
  import * as os from "os";
30
33
  import * as path from "path";
34
+ async function rememberPeer(dir, url) {
35
+ const clean = url.replace(/\/$/, "");
36
+ const stored = await loadStored(dir);
37
+ const peers2 = stored.peers ?? [];
38
+ if (peers2.includes(clean)) return;
39
+ await saveStored(dir, { peers: [...peers2, clean].slice(-32) });
40
+ }
41
+ function cursorKey() {
42
+ return process.env.VOLENET_MCP_SESSION?.trim() || "session";
43
+ }
44
+ function baseDir() {
45
+ return path.join(os.homedir(), ".openvole", "volenet-mcp");
46
+ }
31
47
  function sessionKey(cwd = process.cwd()) {
32
48
  const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 8);
33
49
  const base = (cwd.split("/").filter(Boolean).pop() ?? "session").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 40);
34
50
  return `${base}-${hash}`;
35
51
  }
36
- function defaultDir() {
37
- return process.env.VOLENET_MCP_DIR?.trim() || path.join(os.homedir(), ".openvole", "volenet-mcp");
52
+ function defaultDir(cwd = process.cwd()) {
53
+ const override = process.env.VOLENET_MCP_DIR?.trim();
54
+ return override || path.join(baseDir(), sessionKey(cwd));
38
55
  }
39
- function defaultName() {
40
- return `claude-${os.hostname().split(".")[0].toLowerCase()}`;
56
+ function defaultName(cwd = process.cwd()) {
57
+ const base = (cwd.split("/").filter(Boolean).pop() ?? "session").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 40);
58
+ return `claude-${base}`;
41
59
  }
42
60
  async function loadStored(dir) {
43
61
  try {
@@ -65,8 +83,10 @@ async function resolveSettings() {
65
83
  name: process.env.VOLENET_MCP_NAME?.trim() || stored.name || defaultName(),
66
84
  hub: process.env.VOLENET_MCP_HUB?.trim() || stored.hub || void 0,
67
85
  dir,
68
- port: (Number.isFinite(envPort) && envPort > 0 ? envPort : stored.port) || 9750,
69
- session: process.env.VOLENET_MCP_SESSION?.trim() || sessionKey()
86
+ // 0 by default: a session dials out, and several projects open at once would otherwise
87
+ // queue for one number. A peer that can dial you wants a fixed one — set it then.
88
+ port: (Number.isFinite(envPort) && envPort > 0 ? envPort : stored.port) || 0,
89
+ session: cursorKey()
70
90
  };
71
91
  }
72
92
  var file;
@@ -129,16 +149,48 @@ var init_inbox = __esm({
129
149
  async load() {
130
150
  await this.adoptLegacy();
131
151
  this.messages = await readLog(this.log);
152
+ this.readAt = /* @__PURE__ */ new Map();
153
+ await this.readCursor();
154
+ }
155
+ /**
156
+ * Re-read what has changed on disk since we loaded — both the messages another session
157
+ * appended, and the cursor another *process* moved.
158
+ *
159
+ * The cursor matters as much as the messages: a long-lived server holds one in memory while a
160
+ * hook, running as its own process for the same session, marks things read behind it. Without
161
+ * this the server goes on reporting messages as unread that the person has already been shown.
162
+ */
163
+ async refresh() {
164
+ this.messages = await readLog(this.log);
165
+ await this.readCursor();
166
+ }
167
+ /** Take the later of what we hold and what is on disk: a cursor only ever moves forward. */
168
+ async readCursor() {
132
169
  try {
133
170
  const raw = JSON.parse(await fs2.readFile(this.cursor, "utf-8"));
134
- this.readAt = new Map(Object.entries(raw ?? {}));
171
+ this.merge(raw);
172
+ return;
173
+ } catch {
174
+ }
175
+ const legacy = /^[a-z0-9._-]+-[0-9a-f]{8}\.json$/;
176
+ try {
177
+ const dir = path2.dirname(this.cursor);
178
+ for (const name of await fs2.readdir(dir)) {
179
+ if (!legacy.test(name)) continue;
180
+ try {
181
+ this.merge(JSON.parse(await fs2.readFile(path2.join(dir, name), "utf-8")));
182
+ } catch {
183
+ }
184
+ }
135
185
  } catch {
136
- this.readAt = /* @__PURE__ */ new Map();
137
186
  }
138
187
  }
139
- /** Re-read what other sessions have appended since we loaded. */
140
- async refresh() {
141
- this.messages = await readLog(this.log);
188
+ /** Cursors only move forward, so take the later of what we hold and what was read. */
189
+ merge(raw) {
190
+ if (!raw || typeof raw !== "object") return;
191
+ for (const [peer, at] of Object.entries(raw)) {
192
+ if (typeof at === "number" && at > (this.readAt.get(peer) ?? 0)) this.readAt.set(peer, at);
193
+ }
142
194
  }
143
195
  /** Record a message. Returns false when this id was already recorded. */
144
196
  async add(m) {
@@ -243,7 +295,11 @@ function localNet(m) {
243
295
  return {
244
296
  async identity() {
245
297
  const k = m.getKeyPair();
246
- return k ? { instanceId: k.instanceId, publicKeyString: k.publicKeyString } : null;
298
+ return k ? {
299
+ instanceId: k.instanceId,
300
+ publicKeyString: k.publicKeyString,
301
+ port: m.getTransport()?.getPort?.() ?? void 0
302
+ } : null;
247
303
  },
248
304
  async instances() {
249
305
  const live = new Set(
@@ -549,6 +605,11 @@ async function startLocal(options, inbox, notify) {
549
605
  const notices = [];
550
606
  const listeners = /* @__PURE__ */ new Set();
551
607
  const port = await isFree(options.port) ? options.port : 0;
608
+ const stored = await loadStored(options.dir);
609
+ const dial = [
610
+ ...options.hub ? [options.hub] : [],
611
+ ...(stored.peers ?? []).filter((u) => u !== options.hub)
612
+ ];
552
613
  const manager = new VoleNetManager(
553
614
  {
554
615
  enabled: true,
@@ -556,9 +617,11 @@ async function startLocal(options, inbox, notify) {
556
617
  role: "peer",
557
618
  port,
558
619
  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" }] : []
620
+ // 'read' rather than 'full': these carry our traffic and answer our questions, they
621
+ // have no business acting on this node.
622
+ peers: dial.map((url) => ({ url, trust: "read" })),
623
+ // Pairing learns an address at runtime; without this it is forgotten on exit.
624
+ persistPeer: (url) => rememberPeer(options.dir, url)
562
625
  },
563
626
  options.dir
564
627
  );
@@ -702,6 +765,8 @@ import {
702
765
  // src/cli.ts
703
766
  init_config();
704
767
  init_inbox();
768
+ import * as fsSync2 from "fs";
769
+ import * as fs4 from "fs/promises";
705
770
  import * as path6 from "path";
706
771
  import { loadKeyPair } from "@openvole/volenet";
707
772
 
@@ -718,15 +783,16 @@ function addArgs(scope, command) {
718
783
  return ["mcp", "add", SERVER_NAME, "-s", scope, "--", ...command];
719
784
  }
720
785
  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';
721
- function install(argv, out = process.stdout) {
786
+ var spawnClaude = (args) => spawnSync("claude", args, {
787
+ stdio: ["ignore", "pipe", "pipe"],
788
+ encoding: "utf-8",
789
+ timeout: 3e4
790
+ });
791
+ function install(argv, out = process.stdout, exec = spawnClaude) {
722
792
  const scope = argv.includes("--local") ? "local" : "user";
723
793
  const command = launchCommand();
724
794
  const paste = `claude mcp add ${SERVER_NAME} -s ${scope} -- ${command.join(" ")}`;
725
- const run3 = (args) => spawnSync("claude", args, {
726
- stdio: ["ignore", "pipe", "pipe"],
727
- encoding: "utf-8",
728
- timeout: 3e4
729
- });
795
+ const run3 = exec;
730
796
  const listed = run3(["mcp", "list"]);
731
797
  if (listed.error) {
732
798
  out.write(
@@ -738,10 +804,18 @@ function install(argv, out = process.stdout) {
738
804
  );
739
805
  return 1;
740
806
  }
741
- if (listed.stdout?.includes(`${SERVER_NAME}:`)) {
742
- out.write(`${SERVER_NAME} is already registered \u2014 nothing to do.
807
+ const existing = listed.stdout?.split("\n").find((l) => l.trimStart().startsWith(`${SERVER_NAME}:`));
808
+ if (existing) {
809
+ if (existing.includes(command.join(" "))) {
810
+ out.write(`${SERVER_NAME} is already registered, unchanged.
743
811
  ${NEXT_STEPS}`);
744
- return 0;
812
+ return 0;
813
+ }
814
+ out.write(`Replacing the existing ${SERVER_NAME} registration:
815
+ was: ${existing.trim()}
816
+ `);
817
+ run3(["mcp", "remove", SERVER_NAME, "-s", scope]);
818
+ run3(["mcp", "remove", SERVER_NAME, "-s", scope === "user" ? "local" : "user"]);
745
819
  }
746
820
  const added = run3(addArgs(scope, command));
747
821
  if (added.status !== 0) {
@@ -770,6 +844,8 @@ var USAGE = `volenet-mcp \u2014 VoleNet as an MCP server
770
844
  volenet-mcp whoami this machine's identity on the mesh
771
845
  volenet-mcp daemon run the node in the foreground (normally started for you)
772
846
  volenet-mcp hub [url|--leave] which hub to use; takes effect on the next session
847
+ volenet-mcp adopt take over an identity left at the old shared location
848
+ volenet-mcp wait [--timeout <s>] block until a message arrives, then print it and exit
773
849
  volenet-mcp inbox [--read] [--quiet]
774
850
  messages waiting. --read marks them seen, --quiet says
775
851
  nothing when there are none (for hooks)
@@ -778,7 +854,12 @@ With no command it runs as the MCP server itself, over stdio, which is how Claud
778
854
  Anything needing a live node \u2014 peers, pairing, asking an agent's brain \u2014 is a tool you ask for in
779
855
  a session, not a command here.
780
856
  `;
781
- var when = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 16);
857
+ var when = (ts) => new Date(ts).toLocaleString(void 0, {
858
+ month: "short",
859
+ day: "numeric",
860
+ hour: "2-digit",
861
+ minute: "2-digit"
862
+ });
782
863
  async function run2(argv, out = process.stdout) {
783
864
  const [command, ...rest] = argv;
784
865
  const dir = defaultDir();
@@ -793,6 +874,40 @@ async function run2(argv, out = process.stdout) {
793
874
  await runDaemon2(await resolveSettings2());
794
875
  return 0;
795
876
  }
877
+ if (command === "adopt") {
878
+ const legacy = baseDir();
879
+ const from = path6.join(legacy, "net", "vole_key");
880
+ try {
881
+ await fs4.access(from);
882
+ } catch {
883
+ out.write(`Nothing to adopt: no identity at ${legacy}.
884
+ `);
885
+ return 0;
886
+ }
887
+ try {
888
+ await fs4.access(path6.join(dir, "net", "vole_key"));
889
+ out.write(
890
+ `This directory already has an identity at ${dir}.
891
+ Adopting would overwrite it, so nothing was moved.
892
+ `
893
+ );
894
+ return 1;
895
+ } catch {
896
+ }
897
+ await fs4.mkdir(dir, { recursive: true });
898
+ for (const name of ["net", "messages.jsonl", "config.json", "cursors"]) {
899
+ try {
900
+ await fs4.rename(path6.join(legacy, name), path6.join(dir, name));
901
+ } catch {
902
+ }
903
+ }
904
+ out.write(
905
+ `Adopted the identity at ${legacy} for this directory.
906
+ It keeps its keys, its peers and its history, so nothing needs re-pairing.
907
+ `
908
+ );
909
+ return 0;
910
+ }
796
911
  if (command === "whoami") {
797
912
  const stored = await loadStored(dir);
798
913
  const keys = await loadKeyPair(path6.join(dir, "net")).catch(() => null);
@@ -843,8 +958,51 @@ It is joined the next time the server starts \u2014 restart Claude Code, or ask
843
958
  );
844
959
  return 0;
845
960
  }
961
+ if (command === "wait") {
962
+ const inbox = new Inbox(dir, cursorKey());
963
+ await inbox.load();
964
+ const seconds = Number(rest[rest.indexOf("--timeout") + 1]);
965
+ const limit = Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : 36e5;
966
+ const log = path6.join(dir, "messages.jsonl");
967
+ const arrived = await new Promise((resolve2) => {
968
+ const done = (v) => {
969
+ clearInterval(timer);
970
+ clearTimeout(cap);
971
+ watcher?.close();
972
+ resolve2(v);
973
+ };
974
+ const check = async () => {
975
+ await inbox.refresh().catch(() => void 0);
976
+ const unread = inbox.unread();
977
+ if (unread.length > 0) done(unread);
978
+ };
979
+ let watcher;
980
+ try {
981
+ watcher = fsSync2.watch(path6.dirname(log), (_e, name) => {
982
+ if (name === "messages.jsonl") void check();
983
+ });
984
+ } catch {
985
+ }
986
+ const timer = setInterval(() => void check(), 1e3);
987
+ const cap = setTimeout(() => done([]), limit);
988
+ void check();
989
+ });
990
+ if (arrived.length === 0) {
991
+ out.write(`Nothing arrived within ${Math.round(limit / 1e3)}s.
992
+ `);
993
+ return 0;
994
+ }
995
+ out.write(`${arrived.length} new VoleNet message${arrived.length === 1 ? "" : "s"}:
996
+
997
+ `);
998
+ for (const m of arrived) out.write(` [${when(m.ts)}] ${m.peerName}: ${m.text}
999
+ `);
1000
+ if (!rest.includes("--keep")) await inbox.markRead();
1001
+ out.write("\n");
1002
+ return 0;
1003
+ }
846
1004
  if (command === "inbox") {
847
- const inbox = new Inbox(dir, process.env.VOLENET_MCP_SESSION?.trim() || sessionKey());
1005
+ const inbox = new Inbox(dir, cursorKey());
848
1006
  await inbox.load();
849
1007
  const unread = inbox.unread();
850
1008
  if (unread.length === 0) {
@@ -1059,8 +1217,9 @@ var TOOLS = [
1059
1217
  `instanceId ${key?.instanceId ?? "(not started)"}`,
1060
1218
  `hub ${node.hubStatus}`,
1061
1219
  `connected ${online} peer(s) online`,
1062
- `listening port ${node.options.port} (reachable only from networks that can dial it)`,
1220
+ `listening port ${key?.port ?? node.options.port} (reachable only from networks that can dial it)`,
1063
1221
  `store ${node.options.dir}`,
1222
+ "identity this project directory \u2014 another project is a different peer",
1064
1223
  `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"}`
1065
1224
  ];
1066
1225
  lines.push(
@@ -1419,6 +1578,13 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
1419
1578
  args.brain ? ["brain"] : void 0
1420
1579
  );
1421
1580
  if (!res.ok) return `Could not ask: ${res.error}`;
1581
+ if (res.alreadyTrusted) {
1582
+ return [
1583
+ `${probe.name ?? url} already trusts this session, so there was nothing to ask for \u2014`,
1584
+ "the link is usable now. Call volenet_peers to see it.",
1585
+ args.brain ? "Whether it also allows its brain is a separate grant its operator makes in net.peers; volenet_ask will say plainly if it does not." : ""
1586
+ ].filter(Boolean).join(" ");
1587
+ }
1422
1588
  return [
1423
1589
  `Trusted ${probe.name ?? url} and asked it to trust this session.`,
1424
1590
  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.",
@@ -1439,8 +1605,9 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
1439
1605
  init_inbox();
1440
1606
  init_config();
1441
1607
  init_node();
1442
- function unreadFooter(node, toolName) {
1608
+ async function unreadFooter(node, toolName) {
1443
1609
  if (toolName === "volenet_inbox" || toolName === "volenet_wait") return "";
1610
+ await node.inbox.refresh().catch(() => void 0);
1444
1611
  const unread = node.inbox.unread();
1445
1612
  if (unread.length === 0) return "";
1446
1613
  const who = [...new Set(unread.map((m) => m.peerName))].join(", ");
@@ -1493,7 +1660,9 @@ function createServer2(node) {
1493
1660
  }
1494
1661
  try {
1495
1662
  const text = await tool.run(node, request.params.arguments ?? {});
1496
- return { content: [{ type: "text", text: text + unreadFooter(node, tool.name) }] };
1663
+ return {
1664
+ content: [{ type: "text", text: text + await unreadFooter(node, tool.name) }]
1665
+ };
1497
1666
  } catch (err) {
1498
1667
  return {
1499
1668
  content: [
@@ -1507,10 +1676,10 @@ function createServer2(node) {
1507
1676
  }
1508
1677
  async function recordClientCapabilities(dir, caps) {
1509
1678
  try {
1510
- const fs4 = await import("fs/promises");
1679
+ const fs5 = await import("fs/promises");
1511
1680
  const path7 = await import("path");
1512
- await fs4.mkdir(dir, { recursive: true });
1513
- await fs4.writeFile(
1681
+ await fs5.mkdir(dir, { recursive: true });
1682
+ await fs5.writeFile(
1514
1683
  path7.join(dir, "client.json"),
1515
1684
  `${JSON.stringify(caps ?? {}, null, 2)}
1516
1685
  `,
@@ -1562,14 +1731,17 @@ export {
1562
1731
  Inbox,
1563
1732
  PROMPTS,
1564
1733
  TOOLS,
1734
+ baseDir,
1565
1735
  createServer2 as createServer,
1566
1736
  defaultDir,
1567
1737
  defaultName,
1568
1738
  loadStored,
1569
1739
  recordClientCapabilities,
1740
+ rememberPeer,
1570
1741
  resolveSettings,
1571
1742
  run2 as runCli,
1572
1743
  saveStored,
1744
+ sessionKey,
1573
1745
  startNode,
1574
1746
  unreadFooter
1575
1747
  };