@openvole/volenet-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,8 +32,10 @@ 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
- Add `--user` to register it in every project rather than the current one. If the `claude` CLI is
36
- not on PATH, the installer prints the one line to paste instead of guessing at its config.
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.
37
+ If the `claude` CLI is not on PATH, the installer prints the one line to paste instead of guessing
38
+ at its config.
37
39
 
38
40
  ### Settings
39
41
 
@@ -52,6 +54,21 @@ Environment wins over stored settings, which win over defaults. None of it is re
52
54
 
53
55
  That directory **is** your identity: back it up, and anyone who has it is you.
54
56
 
57
+ ## Slash commands
58
+
59
+ The server ships its flows as MCP prompts, so the client surfaces them as commands — you pick one
60
+ rather than hoping a sentence matches the right tool.
61
+
62
+ | command | what it does |
63
+ |---|---|
64
+ | `whoami` | this session's identity, and whether it can reach anything |
65
+ | `peers` | who is reachable, and by which route |
66
+ | `setup` | get onto the mesh — join a hub, or pair with an agent |
67
+ | `pair` | pair with an agent, fingerprint checked, asking for brain access if wanted |
68
+ | `rooms` | rooms this session is in, and how to say something to one |
69
+ | `catch-up` | read what arrived while away and say what needs answering |
70
+ | `reach` | message a peer and wait for the reply |
71
+
55
72
  ## Commands
56
73
 
57
74
  Alongside the tools, a few things are useful before a session exists, or without one. These touch
@@ -81,6 +98,7 @@ than a command, because it needs a running node and a conversation to happen in.
81
98
  | `volenet_ask` | Ask another **agent's** brain a question and wait for the answer. |
82
99
  | `volenet_requests` | Trust decisions waiting on you; accept or deny. |
83
100
  | `volenet_wait` | Wait for the next message instead of checking again later. |
101
+ | `volenet_room` | Rooms: list, post, create, join, leave, invite. |
84
102
  | `volenet_hub` | Join a hub, leave one, or say which you are on. Remembered. |
85
103
  | `volenet_connect` | Pair with a node, or ask a hub member for consent to chat. |
86
104
 
@@ -109,6 +127,15 @@ things close most of that gap:
109
127
  called again. That is what makes a back-and-forth feel like a conversation instead of a
110
128
  mailbox: say something, wait, get the reply in the same turn.
111
129
 
130
+ **The daemon notifies you when something arrives** — a desktop notification, which is the thing a
131
+ chat client actually does. It is the only part of the machinery that is always running, so it is
132
+ the only part that can. `VOLENET_MCP_NOTIFY=off` silences it; set it to a command name instead and
133
+ that command is run with the title and body as its two arguments.
134
+
135
+ **A message cannot answer itself.** MCP's only server-initiated model call is `sampling`, and
136
+ Claude Code declares no capabilities at all — `volenet_whoami` reports which it is, so nobody waits
137
+ for a reply that cannot come. What is left is making sure an arrived message is *seen* promptly.
138
+
112
139
  For catch-up at the start of a session, ask for the inbox — or have it arrive before you type
113
140
  anything, with a `SessionStart` hook in `.claude/settings.json`:
114
141
 
@@ -124,6 +151,43 @@ anything, with a `SessionStart` hook in `.claude/settings.json`:
124
151
 
125
152
  `--read` marks them seen, since the hook has just put them in front of you.
126
153
 
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.
156
+
157
+ ## The node runs in a daemon
158
+
159
+ An identity that exists only while an editor is open is offline most of the time: senders hold what
160
+ they cannot deliver, hubs record that somebody tried, and nothing arrives until you come back. So
161
+ the node lives in a small daemon — one per identity, started the first time a session wants it,
162
+ outliving every session. You are reachable whether or not anything is open.
163
+
164
+ It also settles what two open editors would otherwise do to each other: two nodes on one identity
165
+ means a hub binds one socket and the other goes deaf. One node, many sessions attached, no race.
166
+
167
+ ```bash
168
+ volenet-mcp daemon # run it in the foreground; normally it is started for you
169
+ ```
170
+
171
+ Reading does not go through it. Messages are an append-only file, so a session reads them directly
172
+ and keeps its own cursor — the protocol covers acting, not looking, and your history is still there
173
+ if the daemon is gone. `VOLENET_MCP_NO_DAEMON=1` keeps the node in the session, which is the
174
+ fallback where spawning is not allowed.
175
+
176
+ ## One identity, several sessions
177
+
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.
182
+
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.
187
+
188
+ `VOLENET_MCP_SESSION` names the reader explicitly, if you want two sessions in one directory kept
189
+ apart.
190
+
127
191
  ## Session lifetime
128
192
 
129
193
  The node lives as long as the editor session. That is a supported shape, not a degraded one: a
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import { VoleNetManager } from '@openvole/volenet';
3
2
 
4
3
  interface StoredConfig {
5
4
  name?: string;
@@ -11,6 +10,8 @@ interface Settings {
11
10
  hub?: string;
12
11
  dir: string;
13
12
  port: number;
13
+ /** Which read state in the shared inbox is this session's. See {@link sessionKey}. */
14
+ session: string;
14
15
  }
15
16
  declare function defaultDir(): string;
16
17
  /** A name that says what this is without needing to be chosen. Identity is the key, not this. */
@@ -32,20 +33,31 @@ interface Message {
32
33
  id: string;
33
34
  }
34
35
  declare class Inbox {
35
- private readonly file;
36
+ private readonly dir;
37
+ private readonly session;
36
38
  private messages;
37
- /** Per peer, the timestamp up to which the session has been shown its messages. */
39
+ /** Per peer, the timestamp up to which *this* session has been shown its messages. */
38
40
  private readAt;
39
41
  private writing;
40
- constructor(file: string);
42
+ /**
43
+ * @param dir where the shared log and the cursors live
44
+ * @param session which read state is ours. Sessions in different projects are different
45
+ * readers; the same project reopened is the same reader, so restarting does
46
+ * not replay everything already seen.
47
+ */
48
+ constructor(dir: string, session?: string);
49
+ private get log();
50
+ private get cursor();
41
51
  load(): Promise<void>;
52
+ /** Re-read what other sessions have appended since we loaded. */
53
+ refresh(): Promise<void>;
42
54
  /** Record a message. Returns false when this id was already recorded. */
43
55
  add(m: Message): Promise<boolean>;
44
56
  /** Everything with one peer, oldest first. */
45
57
  history(peerId: string, limit?: number): Message[];
46
- /** Inbound messages the session has not been shown yet, oldest first. */
58
+ /** Inbound messages this session has not been shown yet, oldest first. */
47
59
  unread(): Message[];
48
- /** Mark everything currently unread as seen. */
60
+ /** Mark everything currently unread as seen — for this session, and nobody else. */
49
61
  markRead(): Promise<void>;
50
62
  /** Every peer we have said anything to or heard anything from, most recent first. */
51
63
  peers(): Array<{
@@ -55,8 +67,145 @@ declare class Inbox {
55
67
  unread: number;
56
68
  }>;
57
69
  get size(): number;
58
- /** Serialised: two messages arriving together must not race each other's rewrite. */
59
- private persist;
70
+ /** One line, one write an append no other session can lose. */
71
+ private append;
72
+ /** Rewrite the log with the newest MAX_MESSAGES. Rare, and atomic via rename. */
73
+ private compact;
74
+ private persistCursor;
75
+ /**
76
+ * Carry over messages written before the log existed.
77
+ *
78
+ * Earlier versions kept one `inbox.json` holding both the messages and a single read state. The
79
+ * messages are still someone's; dropping them on upgrade would lose real conversations. The old
80
+ * read state is deliberately *not* carried over — it was one cursor for every session, so honouring
81
+ * it would mark messages seen for sessions that never saw them. Unread is the safe direction.
82
+ */
83
+ private adoptLegacy;
84
+ }
85
+
86
+ /**
87
+ * Everything the tools need from a node, as a flat surface.
88
+ *
89
+ * The tools used to reach into `VoleNetManager` directly, including through the objects it hands
90
+ * back — `getTransport()?.getPeers()`, `getRemoteTaskManager().delegateTask()`. That is fine while
91
+ * the node lives in the same process, and impossible once it does not: a socket cannot return a
92
+ * transport. Flattening it to plain calls with plain values is what lets the same tools run
93
+ * against a node here or a node in a daemon, which is what being *present* while no session is
94
+ * open requires.
95
+ */
96
+
97
+ interface PeerInfo {
98
+ id: string;
99
+ name: string;
100
+ connected: boolean;
101
+ }
102
+ interface RelayMemberInfo {
103
+ id: string;
104
+ name: string;
105
+ viaHubName: string;
106
+ connected: boolean;
107
+ accepted: boolean;
108
+ incoming: boolean;
109
+ awaiting: boolean;
110
+ }
111
+ interface PairRequestInfo {
112
+ id: string;
113
+ name: string;
114
+ note?: string;
115
+ wants?: string[];
116
+ }
117
+ interface PairGrantInput {
118
+ trust?: 'full' | 'tool' | 'read';
119
+ allowBrain?: boolean;
120
+ }
121
+ interface SendResult {
122
+ ok: boolean;
123
+ delivered?: boolean;
124
+ relayed?: boolean;
125
+ error?: string;
126
+ }
127
+ interface AskResult {
128
+ status: string;
129
+ result?: string;
130
+ error?: string;
131
+ }
132
+ interface Identity {
133
+ instanceId: string;
134
+ publicKeyString: string;
135
+ }
136
+ interface RoomView {
137
+ room: string;
138
+ name: string;
139
+ topic?: string;
140
+ members: Array<{
141
+ instanceId: string;
142
+ name: string;
143
+ }>;
144
+ }
145
+ /** A node, wherever it happens to be running. */
146
+ interface NetLike {
147
+ identity(): Promise<Identity | null>;
148
+ /** Peers this node holds a direct link with, and whether the socket is live. */
149
+ instances(): Promise<PeerInfo[]>;
150
+ relayMembers(): Promise<RelayMemberInfo[]>;
151
+ sendChat(to: string, text: string): Promise<SendResult>;
152
+ askBrain(to: string, input: string, fromName: string, timeoutMs: number): Promise<AskResult>;
153
+ joinHub(url: string): Promise<{
154
+ ok: boolean;
155
+ pending?: boolean;
156
+ hubName?: string;
157
+ error?: string;
158
+ }>;
159
+ addPeer(url: string): Promise<void>;
160
+ forgetPeer(url: string): Promise<boolean>;
161
+ probePair(url: string): Promise<{
162
+ ok: boolean;
163
+ name?: string;
164
+ fingerprint?: string;
165
+ publicKey?: string;
166
+ alreadyTrusted?: boolean;
167
+ error?: string;
168
+ }>;
169
+ initiatePair(url: string, publicKey: string, note?: string, wants?: string[]): Promise<{
170
+ ok: boolean;
171
+ pending?: boolean;
172
+ error?: string;
173
+ }>;
174
+ requestRelayConnect(ref: string, note?: string): Promise<{
175
+ ok: boolean;
176
+ queued?: boolean;
177
+ error?: string;
178
+ }>;
179
+ approveRelayConnect(ref: string): Promise<{
180
+ ok: boolean;
181
+ error?: string;
182
+ }>;
183
+ denyRelayConnect(ref: string): Promise<{
184
+ ok: boolean;
185
+ error?: string;
186
+ }>;
187
+ /** Rooms this node is in, as its hub last described them (PROTOCOL.md §7c). */
188
+ rooms(): Promise<RoomView[]>;
189
+ roomCommand(hub: string, type: 'room:create' | 'room:join' | 'room:leave' | 'room:invite' | 'room:list', payload: Record<string, unknown>): Promise<{
190
+ ok: boolean;
191
+ error?: string;
192
+ }>;
193
+ /** Post to a room: one sealed copy per member, so there is no key and no rotation. */
194
+ postToRoom(room: string, text: string): Promise<{
195
+ ok: boolean;
196
+ sent: number;
197
+ held: number;
198
+ skipped: number;
199
+ error?: string;
200
+ }>;
201
+ listPairRequests(): Promise<PairRequestInfo[]>;
202
+ acceptPair(ref: string, grant?: PairGrantInput): Promise<{
203
+ ok: boolean;
204
+ error?: string;
205
+ }>;
206
+ denyPair(ref: string): Promise<{
207
+ ok: boolean;
208
+ }>;
60
209
  }
61
210
 
62
211
  /** What a node needs to start. Resolved from stored settings, env and defaults. */
@@ -76,28 +225,63 @@ interface Notice {
76
225
  last: number;
77
226
  }
78
227
  interface Node {
79
- net: VoleNetManager;
228
+ net: NetLike;
80
229
  inbox: Inbox;
81
- /** What happened when we tried to join the configured hub, for whoami to report honestly. */
82
- hubStatus: string;
83
230
  /** Trust decisions waiting on the person, newest last. */
84
231
  requests: PendingRequest[];
85
232
  /** Who tried to reach us while we were away, as the hub reports on reconnect. */
86
233
  notices: Notice[];
87
234
  options: NodeOptions;
235
+ /** What happened when the node last tried to join the configured hub. */
236
+ hubStatus: string;
88
237
  /**
89
- * Be told when a message lands. MCP cannot push, so a session that wants to *wait* for a reply
90
- * rather than poll for one needs somewhere to hang a promise. Returns an unsubscribe.
238
+ * Whether the client will run a model when the server asks MCP's `sampling` capability, and
239
+ * the only way an arriving message could ever answer itself. Set once the client has connected.
91
240
  */
241
+ canSample: boolean;
242
+ /** Where this node is running, which decides whether it is there when nothing is open. */
243
+ where: 'daemon' | 'in-process';
244
+ /** Be told when a message lands, so a session can wait for a reply rather than poll for one. */
92
245
  onMessage: (fn: (m: Message) => void) => () => void;
93
246
  stop: () => Promise<void>;
94
247
  }
248
+ /**
249
+ * Attach to this identity's daemon, starting one if none is running.
250
+ *
251
+ * Falls back to a node in this process when a daemon cannot be had — a sandbox that forbids
252
+ * spawning, say. Everything still works; it is simply only present while this session is.
253
+ */
95
254
  declare function startNode(options: NodeOptions): Promise<Node>;
96
255
 
97
256
  declare function run(argv: string[], out?: NodeJS.WriteStream & {
98
257
  fd: 1;
99
258
  }): Promise<number>;
100
259
 
260
+ /**
261
+ * Guided flows, shipped with the server.
262
+ *
263
+ * A tool list tells a session what it *can* do, not what to do first, in what order, or what the
264
+ * words mean. In a fresh session "pair with my agent at <url>" only works if the model happens to
265
+ * match the sentence to `volenet_connect` — which it usually will, and shouldn't have to. Prompts
266
+ * surface in the client as commands, so the flow is chosen rather than guessed.
267
+ *
268
+ * They ship in this package rather than as files written into someone's editor config, so
269
+ * installing is all it takes and they cannot drift from the tools they describe.
270
+ */
271
+ interface PromptArgument {
272
+ name: string;
273
+ description: string;
274
+ required?: boolean;
275
+ }
276
+ interface PromptDef {
277
+ name: string;
278
+ description: string;
279
+ arguments?: PromptArgument[];
280
+ /** The instruction handed to the session, with any arguments already filled in. */
281
+ render: (args: Record<string, string>) => string;
282
+ }
283
+ declare const PROMPTS: PromptDef[];
284
+
101
285
  interface ToolDef {
102
286
  name: string;
103
287
  description: string;
@@ -133,5 +317,7 @@ declare const TOOLS: ToolDef[];
133
317
  declare function unreadFooter(node: Node, toolName: string): string;
134
318
  /** Wire the tools to an MCP server. Separated so a test can drive it without a transport. */
135
319
  declare function createServer(node: Node): Server;
320
+ /** Remember what the client can do, so a later session can say so without asking again. */
321
+ declare function recordClientCapabilities(dir: string, caps: unknown): Promise<void>;
136
322
 
137
- export { Inbox, type Node, type NodeOptions, type Settings, TOOLS, type ToolDef, createServer, defaultDir, defaultName, loadStored, resolveSettings, run as runCli, saveStored, startNode, unreadFooter };
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 };