@openvole/volenet-mcp 0.2.1 → 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 +53 -27
- package/dist/index.d.ts +51 -7
- package/dist/index.js +186 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
|
@@ -36,27 +36,29 @@ Running it again is safe, and is how you move an existing registration: an ident
|
|
|
36
36
|
alone, and one pointing somewhere else — a working-tree build, say, when you have since installed
|
|
37
37
|
the published package — is replaced rather than reported as already done.
|
|
38
38
|
|
|
39
|
-
It registers for **every project
|
|
40
|
-
|
|
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.
|
|
41
41
|
If the `claude` CLI is not on PATH, the installer prints the one line to paste instead of guessing
|
|
42
42
|
at its config.
|
|
43
43
|
|
|
44
44
|
### Settings
|
|
45
45
|
|
|
46
|
-
Settings live with the identity in
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
49
49
|
|
|
50
50
|
| variable | default | what |
|
|
51
51
|
|---|---|---|
|
|
52
|
-
| `VOLENET_MCP_NAME` | `claude-<
|
|
52
|
+
| `VOLENET_MCP_NAME` | `claude-<project>` | What peers see. Not identity — the key is. |
|
|
53
53
|
| `VOLENET_MCP_HUB` | none | Normally set by `volenet_hub`; this overrides it for scripted setups. |
|
|
54
|
-
| `VOLENET_MCP_DIR` | `~/.openvole/volenet-mcp
|
|
55
|
-
| `VOLENET_MCP_PORT` | `
|
|
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. |
|
|
56
58
|
|
|
57
59
|
Environment wins over stored settings, which win over defaults. None of it is required.
|
|
58
60
|
|
|
59
|
-
That directory **is**
|
|
61
|
+
That directory **is** that project's identity: back it up, and anyone who has it is you.
|
|
60
62
|
|
|
61
63
|
## Slash commands
|
|
62
64
|
|
|
@@ -65,7 +67,7 @@ rather than hoping a sentence matches the right tool.
|
|
|
65
67
|
|
|
66
68
|
| command | what it does |
|
|
67
69
|
|---|---|
|
|
68
|
-
| `whoami` | this
|
|
70
|
+
| `whoami` | this project's identity, and whether it can reach anything |
|
|
69
71
|
| `peers` | who is reachable, and by which route |
|
|
70
72
|
| `setup` | get onto the mesh — join a hub, or pair with an agent |
|
|
71
73
|
| `pair` | pair with an agent, fingerprint checked, asking for brain access if wanted |
|
|
@@ -80,11 +82,13 @@ files only — no node is started, no port is bound — so they are safe to run
|
|
|
80
82
|
and safe in a hook that fires on every session.
|
|
81
83
|
|
|
82
84
|
```bash
|
|
83
|
-
volenet-mcp install [--
|
|
84
|
-
volenet-mcp
|
|
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
|
|
85
88
|
volenet-mcp hub <url> set the hub; joined on the next session start
|
|
86
89
|
volenet-mcp hub --leave come off it
|
|
87
90
|
volenet-mcp inbox [--read] what is waiting
|
|
91
|
+
volenet-mcp wait [--timeout s] block until a message arrives, then print it and exit
|
|
88
92
|
```
|
|
89
93
|
|
|
90
94
|
Anything needing a live node — the roster, pairing, asking an agent's brain — is a tool rather
|
|
@@ -140,6 +144,12 @@ that command is run with the title and body as its two arguments.
|
|
|
140
144
|
Claude Code declares no capabilities at all — `volenet_whoami` reports which it is, so nobody waits
|
|
141
145
|
for a reply that cannot come. What is left is making sure an arrived message is *seen* promptly.
|
|
142
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
|
+
|
|
143
153
|
For catch-up at the start of a session, ask for the inbox — or have it arrive before you type
|
|
144
154
|
anything, with a `SessionStart` hook in `.claude/settings.json`:
|
|
145
155
|
|
|
@@ -155,8 +165,8 @@ anything, with a `SessionStart` hook in `.claude/settings.json`:
|
|
|
155
165
|
|
|
156
166
|
`--read` marks them seen, since the hook has just put them in front of you.
|
|
157
167
|
|
|
158
|
-
Swap `SessionStart` for `UserPromptSubmit` and waiting messages arrive on every turn you take
|
|
159
|
-
|
|
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.
|
|
160
170
|
|
|
161
171
|
## The node runs in a daemon
|
|
162
172
|
|
|
@@ -177,20 +187,36 @@ and keeps its own cursor — the protocol covers acting, not looking, and your h
|
|
|
177
187
|
if the daemon is gone. `VOLENET_MCP_NO_DAEMON=1` keeps the node in the session, which is the
|
|
178
188
|
fallback where spawning is not allowed.
|
|
179
189
|
|
|
180
|
-
## One identity
|
|
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.
|
|
181
208
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
186
213
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
shown; and two projects open at once are two readers of one log. Appending rather than rewriting
|
|
190
|
-
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.
|
|
191
216
|
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
194
220
|
|
|
195
221
|
## Session lifetime
|
|
196
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
|
|
23
|
+
/** Which read state in this directory's inbox is ours. See {@link sessionKey}. */
|
|
14
24
|
session: string;
|
|
15
25
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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.
|
|
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
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
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 ? {
|
|
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
|
-
//
|
|
560
|
-
//
|
|
561
|
-
peers:
|
|
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
|
|
|
@@ -779,6 +844,8 @@ var USAGE = `volenet-mcp \u2014 VoleNet as an MCP server
|
|
|
779
844
|
volenet-mcp whoami this machine's identity on the mesh
|
|
780
845
|
volenet-mcp daemon run the node in the foreground (normally started for you)
|
|
781
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
|
|
782
849
|
volenet-mcp inbox [--read] [--quiet]
|
|
783
850
|
messages waiting. --read marks them seen, --quiet says
|
|
784
851
|
nothing when there are none (for hooks)
|
|
@@ -787,7 +854,12 @@ With no command it runs as the MCP server itself, over stdio, which is how Claud
|
|
|
787
854
|
Anything needing a live node \u2014 peers, pairing, asking an agent's brain \u2014 is a tool you ask for in
|
|
788
855
|
a session, not a command here.
|
|
789
856
|
`;
|
|
790
|
-
var when = (ts) => new Date(ts).
|
|
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
|
+
});
|
|
791
863
|
async function run2(argv, out = process.stdout) {
|
|
792
864
|
const [command, ...rest] = argv;
|
|
793
865
|
const dir = defaultDir();
|
|
@@ -802,6 +874,40 @@ async function run2(argv, out = process.stdout) {
|
|
|
802
874
|
await runDaemon2(await resolveSettings2());
|
|
803
875
|
return 0;
|
|
804
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
|
+
}
|
|
805
911
|
if (command === "whoami") {
|
|
806
912
|
const stored = await loadStored(dir);
|
|
807
913
|
const keys = await loadKeyPair(path6.join(dir, "net")).catch(() => null);
|
|
@@ -852,8 +958,51 @@ It is joined the next time the server starts \u2014 restart Claude Code, or ask
|
|
|
852
958
|
);
|
|
853
959
|
return 0;
|
|
854
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
|
+
}
|
|
855
1004
|
if (command === "inbox") {
|
|
856
|
-
const inbox = new Inbox(dir,
|
|
1005
|
+
const inbox = new Inbox(dir, cursorKey());
|
|
857
1006
|
await inbox.load();
|
|
858
1007
|
const unread = inbox.unread();
|
|
859
1008
|
if (unread.length === 0) {
|
|
@@ -1068,8 +1217,9 @@ var TOOLS = [
|
|
|
1068
1217
|
`instanceId ${key?.instanceId ?? "(not started)"}`,
|
|
1069
1218
|
`hub ${node.hubStatus}`,
|
|
1070
1219
|
`connected ${online} peer(s) online`,
|
|
1071
|
-
`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)`,
|
|
1072
1221
|
`store ${node.options.dir}`,
|
|
1222
|
+
"identity this project directory \u2014 another project is a different peer",
|
|
1073
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"}`
|
|
1074
1224
|
];
|
|
1075
1225
|
lines.push(
|
|
@@ -1428,6 +1578,13 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
|
|
|
1428
1578
|
args.brain ? ["brain"] : void 0
|
|
1429
1579
|
);
|
|
1430
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
|
+
}
|
|
1431
1588
|
return [
|
|
1432
1589
|
`Trusted ${probe.name ?? url} and asked it to trust this session.`,
|
|
1433
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.",
|
|
@@ -1448,8 +1605,9 @@ Call with leave:true to come off it, or url to move to another.` : "Not on a hub
|
|
|
1448
1605
|
init_inbox();
|
|
1449
1606
|
init_config();
|
|
1450
1607
|
init_node();
|
|
1451
|
-
function unreadFooter(node, toolName) {
|
|
1608
|
+
async function unreadFooter(node, toolName) {
|
|
1452
1609
|
if (toolName === "volenet_inbox" || toolName === "volenet_wait") return "";
|
|
1610
|
+
await node.inbox.refresh().catch(() => void 0);
|
|
1453
1611
|
const unread = node.inbox.unread();
|
|
1454
1612
|
if (unread.length === 0) return "";
|
|
1455
1613
|
const who = [...new Set(unread.map((m) => m.peerName))].join(", ");
|
|
@@ -1502,7 +1660,9 @@ function createServer2(node) {
|
|
|
1502
1660
|
}
|
|
1503
1661
|
try {
|
|
1504
1662
|
const text = await tool.run(node, request.params.arguments ?? {});
|
|
1505
|
-
return {
|
|
1663
|
+
return {
|
|
1664
|
+
content: [{ type: "text", text: text + await unreadFooter(node, tool.name) }]
|
|
1665
|
+
};
|
|
1506
1666
|
} catch (err) {
|
|
1507
1667
|
return {
|
|
1508
1668
|
content: [
|
|
@@ -1516,10 +1676,10 @@ function createServer2(node) {
|
|
|
1516
1676
|
}
|
|
1517
1677
|
async function recordClientCapabilities(dir, caps) {
|
|
1518
1678
|
try {
|
|
1519
|
-
const
|
|
1679
|
+
const fs5 = await import("fs/promises");
|
|
1520
1680
|
const path7 = await import("path");
|
|
1521
|
-
await
|
|
1522
|
-
await
|
|
1681
|
+
await fs5.mkdir(dir, { recursive: true });
|
|
1682
|
+
await fs5.writeFile(
|
|
1523
1683
|
path7.join(dir, "client.json"),
|
|
1524
1684
|
`${JSON.stringify(caps ?? {}, null, 2)}
|
|
1525
1685
|
`,
|
|
@@ -1571,14 +1731,17 @@ export {
|
|
|
1571
1731
|
Inbox,
|
|
1572
1732
|
PROMPTS,
|
|
1573
1733
|
TOOLS,
|
|
1734
|
+
baseDir,
|
|
1574
1735
|
createServer2 as createServer,
|
|
1575
1736
|
defaultDir,
|
|
1576
1737
|
defaultName,
|
|
1577
1738
|
loadStored,
|
|
1578
1739
|
recordClientCapabilities,
|
|
1740
|
+
rememberPeer,
|
|
1579
1741
|
resolveSettings,
|
|
1580
1742
|
run2 as runCli,
|
|
1581
1743
|
saveStored,
|
|
1744
|
+
sessionKey,
|
|
1582
1745
|
startNode,
|
|
1583
1746
|
unreadFooter
|
|
1584
1747
|
};
|