@vdoninja/ninja-p2p 0.1.1 → 0.1.4
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/.agents/skills/ninja-p2p/SKILL.md +142 -0
- package/.agents/skills/ninja-p2p/agents/openai.yaml +5 -0
- package/.claude/skills/ninja-p2p/SKILL.md +130 -0
- package/.codex/skills/ninja-p2p/SKILL.md +142 -0
- package/.codex/skills/ninja-p2p/agents/openai.yaml +5 -0
- package/README.md +580 -55
- package/dashboard.html +595 -56
- package/dist/agent-state.d.ts +167 -0
- package/dist/agent-state.js +311 -0
- package/dist/cli-lib.d.ts +132 -0
- package/dist/cli-lib.js +602 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +1337 -0
- package/dist/file-transfer.d.ts +54 -0
- package/dist/file-transfer.js +241 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/message-bus.d.ts +3 -1
- package/dist/message-bus.js +33 -16
- package/dist/peer-registry.d.ts +2 -1
- package/dist/peer-registry.js +4 -0
- package/dist/protocol.d.ts +57 -1
- package/dist/shared-folders.d.ts +31 -0
- package/dist/shared-folders.js +137 -0
- package/dist/vdo-bridge.d.ts +13 -1
- package/dist/vdo-bridge.js +59 -13
- package/docs/images/agent-room-dashboard.png +0 -0
- package/docs/protocol-and-reliability.md +38 -0
- package/package.json +29 -3
- package/skills/ninja-p2p/SKILL.md +0 -70
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function parseSharedFolderSpecs(rawValues, cwd = process.cwd()) {
|
|
4
|
+
const shares = [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
for (const raw of rawValues.map((value) => value.trim()).filter(Boolean)) {
|
|
7
|
+
const separatorIndex = raw.indexOf("=");
|
|
8
|
+
if (separatorIndex <= 0 || separatorIndex === raw.length - 1) {
|
|
9
|
+
throw new Error(`invalid share spec: ${raw}; use --share name=path`);
|
|
10
|
+
}
|
|
11
|
+
const name = raw.slice(0, separatorIndex).trim();
|
|
12
|
+
const folderPath = raw.slice(separatorIndex + 1).trim();
|
|
13
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
14
|
+
throw new Error(`invalid share name: ${name}`);
|
|
15
|
+
}
|
|
16
|
+
const key = name.toLowerCase();
|
|
17
|
+
if (seen.has(key)) {
|
|
18
|
+
throw new Error(`duplicate share name: ${name}`);
|
|
19
|
+
}
|
|
20
|
+
const resolved = path.resolve(cwd, folderPath);
|
|
21
|
+
if (!existsSync(resolved)) {
|
|
22
|
+
throw new Error(`shared folder does not exist: ${resolved}`);
|
|
23
|
+
}
|
|
24
|
+
const stats = statSync(resolved);
|
|
25
|
+
if (!stats.isDirectory()) {
|
|
26
|
+
throw new Error(`shared folder is not a directory: ${resolved}`);
|
|
27
|
+
}
|
|
28
|
+
seen.add(key);
|
|
29
|
+
shares.push({
|
|
30
|
+
name,
|
|
31
|
+
path: resolved,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return shares;
|
|
35
|
+
}
|
|
36
|
+
export function toSharedFolderSummaries(sharedFolders) {
|
|
37
|
+
return sharedFolders.map((share) => {
|
|
38
|
+
const summary = { name: share.name };
|
|
39
|
+
if (share.description)
|
|
40
|
+
summary.description = share.description;
|
|
41
|
+
return summary;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function listSharedFolderEntries(sharedFolders, shareName, relativePath = "", maxEntries = 200) {
|
|
45
|
+
const share = requireSharedFolder(sharedFolders, shareName);
|
|
46
|
+
const resolved = resolveSharedPath(share, relativePath);
|
|
47
|
+
const stats = statSync(resolved.filePath);
|
|
48
|
+
if (!stats.isDirectory()) {
|
|
49
|
+
throw new Error(`shared path is not a directory: ${resolved.path || "."}`);
|
|
50
|
+
}
|
|
51
|
+
const entries = readdirSync(resolved.filePath, { withFileTypes: true })
|
|
52
|
+
.map((entry) => {
|
|
53
|
+
const childPath = resolved.path ? `${resolved.path}/${entry.name}` : entry.name;
|
|
54
|
+
const absolutePath = path.join(resolved.filePath, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
return {
|
|
57
|
+
name: entry.name,
|
|
58
|
+
path: childPath,
|
|
59
|
+
type: "dir",
|
|
60
|
+
size: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
name: entry.name,
|
|
65
|
+
path: childPath,
|
|
66
|
+
type: "file",
|
|
67
|
+
size: statSync(absolutePath).size,
|
|
68
|
+
};
|
|
69
|
+
})
|
|
70
|
+
.sort((a, b) => Number(a.type === "file") - Number(b.type === "file") || a.name.localeCompare(b.name));
|
|
71
|
+
return {
|
|
72
|
+
share: share.name,
|
|
73
|
+
path: resolved.path,
|
|
74
|
+
entries: entries.slice(0, Math.max(1, maxEntries)),
|
|
75
|
+
truncated: entries.length > maxEntries,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function resolveSharedFile(sharedFolders, shareName, relativePath) {
|
|
79
|
+
const share = requireSharedFolder(sharedFolders, shareName);
|
|
80
|
+
const resolved = resolveSharedPath(share, relativePath);
|
|
81
|
+
const stats = statSync(resolved.filePath);
|
|
82
|
+
if (!stats.isFile()) {
|
|
83
|
+
throw new Error(`shared path is not a file: ${resolved.path || "."}`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
share: share.name,
|
|
87
|
+
path: resolved.path,
|
|
88
|
+
filePath: resolved.filePath,
|
|
89
|
+
name: path.basename(resolved.filePath),
|
|
90
|
+
kind: inferTransferKind(resolved.filePath),
|
|
91
|
+
size: stats.size,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function inferTransferKind(filePath) {
|
|
95
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
96
|
+
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext)) {
|
|
97
|
+
return "image";
|
|
98
|
+
}
|
|
99
|
+
return "file";
|
|
100
|
+
}
|
|
101
|
+
function requireSharedFolder(sharedFolders, shareName) {
|
|
102
|
+
const share = sharedFolders.find((item) => item.name.toLowerCase() === shareName.trim().toLowerCase());
|
|
103
|
+
if (!share) {
|
|
104
|
+
throw new Error(`unknown share: ${shareName}`);
|
|
105
|
+
}
|
|
106
|
+
return share;
|
|
107
|
+
}
|
|
108
|
+
function resolveSharedPath(share, relativePath) {
|
|
109
|
+
const normalized = normalizeRelativeSharePath(relativePath);
|
|
110
|
+
const target = path.resolve(share.path, normalized || ".");
|
|
111
|
+
if (target !== share.path && !target.startsWith(`${share.path}${path.sep}`)) {
|
|
112
|
+
throw new Error("shared path escapes the declared folder");
|
|
113
|
+
}
|
|
114
|
+
if (!existsSync(target)) {
|
|
115
|
+
throw new Error(`shared path does not exist: ${normalized || "."}`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
filePath: target,
|
|
119
|
+
path: normalized,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function normalizeRelativeSharePath(relativePath) {
|
|
123
|
+
const raw = relativePath.replace(/\\/g, "/").trim();
|
|
124
|
+
if (!raw)
|
|
125
|
+
return "";
|
|
126
|
+
if (raw.startsWith("/")) {
|
|
127
|
+
throw new Error("shared path must be relative");
|
|
128
|
+
}
|
|
129
|
+
const normalized = path.posix.normalize(raw);
|
|
130
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
131
|
+
throw new Error("shared path escapes the declared folder");
|
|
132
|
+
}
|
|
133
|
+
if (normalized.includes("/../")) {
|
|
134
|
+
throw new Error("shared path escapes the declared folder");
|
|
135
|
+
}
|
|
136
|
+
return normalized === "." ? "" : normalized;
|
|
137
|
+
}
|
package/dist/vdo-bridge.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from "node:events";
|
|
11
11
|
import { MessageBus, type MessageBusOptions } from "./message-bus.js";
|
|
12
12
|
import { PeerRegistry } from "./peer-registry.js";
|
|
13
|
-
import { type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity } from "./protocol.js";
|
|
13
|
+
import { type AgentProfile, type FileOfferPayload, type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity } from "./protocol.js";
|
|
14
14
|
export type VDOBridgeOptions = {
|
|
15
15
|
room: string;
|
|
16
16
|
streamId: string;
|
|
@@ -23,6 +23,8 @@ export type VDOBridgeOptions = {
|
|
|
23
23
|
skills?: string[];
|
|
24
24
|
/** Topics to subscribe to. */
|
|
25
25
|
topics?: string[];
|
|
26
|
+
/** Optional agent profile advertised to peers. */
|
|
27
|
+
agentProfile?: AgentProfile;
|
|
26
28
|
/** Heartbeat interval in ms. Default: 30000. */
|
|
27
29
|
heartbeatMs?: number;
|
|
28
30
|
/** MessageBus options. */
|
|
@@ -40,14 +42,19 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
40
42
|
private status;
|
|
41
43
|
private statusDetail;
|
|
42
44
|
private version;
|
|
45
|
+
private agentProfile;
|
|
46
|
+
private readonly viewedStreamIds;
|
|
43
47
|
constructor(options: VDOBridgeOptions);
|
|
44
48
|
connect(): Promise<void>;
|
|
45
49
|
disconnect(): Promise<void>;
|
|
46
50
|
isConnected(): boolean;
|
|
51
|
+
private emitBridgeError;
|
|
47
52
|
/** Update skills and broadcast the change. */
|
|
48
53
|
updateSkills(skills: string[]): void;
|
|
49
54
|
/** Update status and broadcast the change. */
|
|
50
55
|
updateStatus(status: string, detail?: string): void;
|
|
56
|
+
/** Update the advertised agent profile and broadcast the change. */
|
|
57
|
+
updateAgentProfile(profile: AgentProfile): void;
|
|
51
58
|
/** Get current announce payload. */
|
|
52
59
|
getAnnouncePayload(): AnnouncePayload;
|
|
53
60
|
/** Send a chat message to everyone or a specific peer. */
|
|
@@ -58,6 +65,10 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
58
65
|
command(targetStreamId: string, command: string, args?: unknown): MessageEnvelope;
|
|
59
66
|
/** Publish an event to a topic. */
|
|
60
67
|
publishEvent(topic: string, kind: string, data?: unknown): MessageEnvelope;
|
|
68
|
+
/** Send a file to a connected peer. */
|
|
69
|
+
sendFile(targetStreamId: string, filePath: string): FileOfferPayload;
|
|
70
|
+
/** Send an image to a connected peer. */
|
|
71
|
+
sendImage(targetStreamId: string, filePath: string): FileOfferPayload;
|
|
61
72
|
/** Send raw data through the underlying SDK without envelope wrapping. */
|
|
62
73
|
sendRaw(data: unknown, targetStreamId?: string): boolean;
|
|
63
74
|
/** Reply to a received message using its sender as the target. */
|
|
@@ -75,4 +86,5 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
75
86
|
private stopHeartbeat;
|
|
76
87
|
private respondPong;
|
|
77
88
|
private broadcastSkillUpdate;
|
|
89
|
+
private maybeViewPeer;
|
|
78
90
|
}
|
package/dist/vdo-bridge.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Zero dependencies on stevesbot internals.
|
|
9
9
|
*/
|
|
10
10
|
import { EventEmitter } from "node:events";
|
|
11
|
+
import { sendFileFromPath } from "./file-transfer.js";
|
|
11
12
|
import { MessageBus } from "./message-bus.js";
|
|
12
13
|
import { PeerRegistry } from "./peer-registry.js";
|
|
13
14
|
import { createEnvelope, createInstanceId, envelopeToWire, parseEnvelope, } from "./protocol.js";
|
|
@@ -23,7 +24,9 @@ export class VDOBridge extends EventEmitter {
|
|
|
23
24
|
skills;
|
|
24
25
|
status = "idle";
|
|
25
26
|
statusDetail = "";
|
|
26
|
-
version = "0.1.
|
|
27
|
+
version = "0.1.4";
|
|
28
|
+
agentProfile;
|
|
29
|
+
viewedStreamIds = new Set();
|
|
27
30
|
constructor(options) {
|
|
28
31
|
super();
|
|
29
32
|
this.options = options;
|
|
@@ -34,6 +37,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
34
37
|
instanceId: createInstanceId(),
|
|
35
38
|
};
|
|
36
39
|
this.skills = options.skills ?? [];
|
|
40
|
+
this.agentProfile = options.agentProfile;
|
|
37
41
|
this.peers = new PeerRegistry();
|
|
38
42
|
this.bus = new MessageBus(this.identity, this.peers, options.busOptions);
|
|
39
43
|
// Subscribe to requested topics
|
|
@@ -58,12 +62,13 @@ export class VDOBridge extends EventEmitter {
|
|
|
58
62
|
// Set the send function on the bus
|
|
59
63
|
this.bus.setSendDataFn((data, target) => {
|
|
60
64
|
if (!this.sdk)
|
|
61
|
-
return;
|
|
65
|
+
return false;
|
|
62
66
|
try {
|
|
63
|
-
this.sdk.sendData(data, target ?? undefined);
|
|
67
|
+
return this.sdk.sendData(data, target ?? undefined) !== false;
|
|
64
68
|
}
|
|
65
69
|
catch (err) {
|
|
66
|
-
this.
|
|
70
|
+
this.emitBridgeError(err);
|
|
71
|
+
return false;
|
|
67
72
|
}
|
|
68
73
|
});
|
|
69
74
|
await this.sdk.connect();
|
|
@@ -100,6 +105,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
100
105
|
catch { /* best effort */ }
|
|
101
106
|
this.connected = false;
|
|
102
107
|
this.sdk = null;
|
|
108
|
+
this.viewedStreamIds.clear();
|
|
103
109
|
this.peers.clear();
|
|
104
110
|
console.log("[P2P] Disconnected.");
|
|
105
111
|
this.emit("disconnected");
|
|
@@ -107,6 +113,13 @@ export class VDOBridge extends EventEmitter {
|
|
|
107
113
|
isConnected() {
|
|
108
114
|
return this.connected;
|
|
109
115
|
}
|
|
116
|
+
emitBridgeError(err) {
|
|
117
|
+
if (this.listenerCount("error") > 0) {
|
|
118
|
+
this.emit("error", err);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
console.error("[P2P] Bridge error:", err);
|
|
122
|
+
}
|
|
110
123
|
// ── Identity Management ──────────────────────────────────────────────────
|
|
111
124
|
/** Update skills and broadcast the change. */
|
|
112
125
|
updateSkills(skills) {
|
|
@@ -119,6 +132,11 @@ export class VDOBridge extends EventEmitter {
|
|
|
119
132
|
this.statusDetail = detail ?? "";
|
|
120
133
|
this.broadcastSkillUpdate();
|
|
121
134
|
}
|
|
135
|
+
/** Update the advertised agent profile and broadcast the change. */
|
|
136
|
+
updateAgentProfile(profile) {
|
|
137
|
+
this.agentProfile = profile;
|
|
138
|
+
this.broadcastSkillUpdate();
|
|
139
|
+
}
|
|
122
140
|
/** Get current announce payload. */
|
|
123
141
|
getAnnouncePayload() {
|
|
124
142
|
return {
|
|
@@ -127,6 +145,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
127
145
|
statusDetail: this.statusDetail,
|
|
128
146
|
version: this.version,
|
|
129
147
|
topics: this.bus.getSubscriptions(),
|
|
148
|
+
agent: this.agentProfile,
|
|
130
149
|
};
|
|
131
150
|
}
|
|
132
151
|
// ── Convenience Methods ──────────────────────────────────────────────────
|
|
@@ -149,26 +168,32 @@ export class VDOBridge extends EventEmitter {
|
|
|
149
168
|
publishEvent(topic, kind, data) {
|
|
150
169
|
return this.bus.publish(topic, "event", { kind, ...((data && typeof data === "object") ? data : { data }) });
|
|
151
170
|
}
|
|
171
|
+
/** Send a file to a connected peer. */
|
|
172
|
+
sendFile(targetStreamId, filePath) {
|
|
173
|
+
return sendFileFromPath(this, targetStreamId, filePath, "file");
|
|
174
|
+
}
|
|
175
|
+
/** Send an image to a connected peer. */
|
|
176
|
+
sendImage(targetStreamId, filePath) {
|
|
177
|
+
return sendFileFromPath(this, targetStreamId, filePath, "image");
|
|
178
|
+
}
|
|
152
179
|
/** Send raw data through the underlying SDK without envelope wrapping. */
|
|
153
180
|
sendRaw(data, targetStreamId) {
|
|
154
181
|
if (!this.sdk)
|
|
155
182
|
return false;
|
|
156
183
|
try {
|
|
157
184
|
if (!targetStreamId) {
|
|
158
|
-
this.sdk.sendData(data);
|
|
159
|
-
return true;
|
|
185
|
+
return this.sdk.sendData(data, { allowFallback: true }) !== false;
|
|
160
186
|
}
|
|
161
187
|
const peer = this.peers.getPeer(targetStreamId);
|
|
162
188
|
if (peer?.uuid) {
|
|
163
|
-
this.sdk.sendData(data, {
|
|
189
|
+
return this.sdk.sendData(data, { uuid: peer.uuid, allowFallback: true }) !== false;
|
|
164
190
|
}
|
|
165
191
|
else {
|
|
166
|
-
this.sdk.sendData(data, { streamID: targetStreamId });
|
|
192
|
+
return this.sdk.sendData(data, { streamID: targetStreamId, allowFallback: true }) !== false;
|
|
167
193
|
}
|
|
168
|
-
return true;
|
|
169
194
|
}
|
|
170
195
|
catch (err) {
|
|
171
|
-
this.
|
|
196
|
+
this.emitBridgeError(err);
|
|
172
197
|
return false;
|
|
173
198
|
}
|
|
174
199
|
}
|
|
@@ -262,6 +287,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
262
287
|
switch (envelope.type) {
|
|
263
288
|
case "announce":
|
|
264
289
|
this.peers.updateFromAnnounce(senderStreamId, envelope.from, envelope.payload);
|
|
290
|
+
this.bus.flushOfflineQueue(senderStreamId);
|
|
265
291
|
this.emit("peer:announce", { streamId: senderStreamId, identity: envelope.from, announce: envelope.payload });
|
|
266
292
|
break;
|
|
267
293
|
case "skill_update":
|
|
@@ -297,6 +323,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
297
323
|
this.sdk.addEventListener("peerDisconnected", (event) => {
|
|
298
324
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
299
325
|
const streamId = event.detail?.streamID ?? this.peers.streamIdForUuid(uuid) ?? uuid;
|
|
326
|
+
this.viewedStreamIds.delete(streamId);
|
|
300
327
|
this.peers.markDisconnected(streamId);
|
|
301
328
|
console.log(`[P2P] Peer disconnected: ${streamId}`);
|
|
302
329
|
this.emit("peer:disconnected", { streamId, uuid });
|
|
@@ -315,15 +342,20 @@ export class VDOBridge extends EventEmitter {
|
|
|
315
342
|
const list = event.detail?.list ?? [];
|
|
316
343
|
for (const entry of list) {
|
|
317
344
|
if (entry.streamID && entry.streamID !== this.options.streamId) {
|
|
318
|
-
|
|
319
|
-
this.sdk.view(entry.streamID, { audio: false, video: false });
|
|
345
|
+
this.maybeViewPeer(entry.streamID);
|
|
320
346
|
}
|
|
321
347
|
}
|
|
322
348
|
});
|
|
349
|
+
this.sdk.addEventListener("videoaddedtoroom", (event) => {
|
|
350
|
+
this.maybeViewPeer(event.detail?.streamID);
|
|
351
|
+
});
|
|
352
|
+
this.sdk.addEventListener("streamAdded", (event) => {
|
|
353
|
+
this.maybeViewPeer(event.detail?.streamID);
|
|
354
|
+
});
|
|
323
355
|
// Error handling
|
|
324
356
|
this.sdk.addEventListener("error", (event) => {
|
|
325
357
|
console.error("[P2P] SDK error:", event.detail?.error);
|
|
326
|
-
this.
|
|
358
|
+
this.emitBridgeError(event.detail?.error);
|
|
327
359
|
});
|
|
328
360
|
}
|
|
329
361
|
// ── Heartbeat ────────────────────────────────────────────────────────────
|
|
@@ -371,6 +403,20 @@ export class VDOBridge extends EventEmitter {
|
|
|
371
403
|
skills: this.skills,
|
|
372
404
|
status: this.status,
|
|
373
405
|
statusDetail: this.statusDetail,
|
|
406
|
+
agent: this.agentProfile,
|
|
374
407
|
});
|
|
375
408
|
}
|
|
409
|
+
maybeViewPeer(streamId) {
|
|
410
|
+
if (!this.sdk || !streamId || streamId === this.options.streamId || this.viewedStreamIds.has(streamId)) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
this.viewedStreamIds.add(streamId);
|
|
414
|
+
try {
|
|
415
|
+
this.sdk.view(streamId, { audio: false, video: false });
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
this.viewedStreamIds.delete(streamId);
|
|
419
|
+
this.emitBridgeError(err);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
376
422
|
}
|
|
Binary file
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Protocol and reliability
|
|
2
|
+
|
|
3
|
+
`ninja-p2p` is an agent coordination layer over VDO.Ninja WebRTC data channels. It does not replace or extend VDO.Ninja signaling.
|
|
4
|
+
|
|
5
|
+
## Layers
|
|
6
|
+
|
|
7
|
+
1. The VDO.Ninja SDK joins a room, discovers published stream IDs, and establishes WebRTC peer connections and data channels.
|
|
8
|
+
2. `VDOBridge` asks the SDK to view discovered peers as data-only connections.
|
|
9
|
+
3. `MessageBus` sends a versioned JSON envelope through those channels.
|
|
10
|
+
4. The optional sidecar stores inbox and outbox records on the local machine so a turn-based agent can read them later.
|
|
11
|
+
|
|
12
|
+
The application envelope contains an ID, timestamp, sender identity, message type, optional target/topic, and payload. It is data carried through VDO.Ninja, not a new WebSocket command.
|
|
13
|
+
|
|
14
|
+
## Delivery semantics
|
|
15
|
+
|
|
16
|
+
- Direct messages are sent when the SDK accepts them for an open data channel.
|
|
17
|
+
- If a known peer is offline, or the channel exists but is not ready yet, the message is queued in memory.
|
|
18
|
+
- A queue flush retries the original envelope, including its message ID and type, and removes only messages the SDK accepted. A failed flush leaves the remaining messages in order for the next channel-open or announce event.
|
|
19
|
+
- The persistent CLI sidecar additionally stores actions on disk before its live process sends them.
|
|
20
|
+
- There is no durable remote broker and no exactly-once guarantee. Applications that require confirmation should use message IDs with `ack` or `command_response` and make handlers idempotent.
|
|
21
|
+
- Room broadcasts are best-effort. They are not retained independently for every absent peer.
|
|
22
|
+
|
|
23
|
+
## Connection race this prevents
|
|
24
|
+
|
|
25
|
+
WebRTC emits several milestones. `peerConnected` can occur before `dataChannelOpen`. Older code treated the first event as send-ready, so an agent replying in that interval could receive a normal-looking local envelope even though the SDK rejected the send. The bridge now observes the SDK's boolean send result and queues an explicitly rejected direct message.
|
|
26
|
+
|
|
27
|
+
A second race occurred during reconnect: the queue was deleted before replay attempts. If the data channel closed between the open event and the first replay, every queued message was lost locally. Queue entries now remain until each replay is accepted.
|
|
28
|
+
|
|
29
|
+
## Compatibility
|
|
30
|
+
|
|
31
|
+
- The wire envelope format is unchanged.
|
|
32
|
+
- Existing send callbacks that return `void` remain accepted; only an explicit `false` means the transport rejected a send.
|
|
33
|
+
- SDK targets use the SDK's documented `{ uuid }` or `{ streamID }` forms.
|
|
34
|
+
- Existing VDO.Ninja room and signaling behavior remains authoritative.
|
|
35
|
+
|
|
36
|
+
## Security boundary
|
|
37
|
+
|
|
38
|
+
WebRTC encrypts data in transit. Room names and optional VDO.Ninja passwords control discovery/connection behavior, but they are not an application authorization system. Validate commands at the receiving agent, expose only intentional shared folders, and do not treat peer-supplied names or capabilities as trusted identity claims.
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vdoninja/ninja-p2p",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Open source P2P agent messaging and coordination over VDO.Ninja WebRTC data channels",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"ninja-p2p": "./dist/cli.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"types": "./dist/index.d.ts",
|
|
@@ -22,6 +25,18 @@
|
|
|
22
25
|
"types": "./dist/peer-registry.d.ts",
|
|
23
26
|
"import": "./dist/peer-registry.js"
|
|
24
27
|
},
|
|
28
|
+
"./file-transfer": {
|
|
29
|
+
"types": "./dist/file-transfer.d.ts",
|
|
30
|
+
"import": "./dist/file-transfer.js"
|
|
31
|
+
},
|
|
32
|
+
"./agent-state": {
|
|
33
|
+
"types": "./dist/agent-state.d.ts",
|
|
34
|
+
"import": "./dist/agent-state.js"
|
|
35
|
+
},
|
|
36
|
+
"./shared-folders": {
|
|
37
|
+
"types": "./dist/shared-folders.d.ts",
|
|
38
|
+
"import": "./dist/shared-folders.js"
|
|
39
|
+
},
|
|
25
40
|
"./protocol": {
|
|
26
41
|
"types": "./dist/protocol.d.ts",
|
|
27
42
|
"import": "./dist/protocol.js"
|
|
@@ -33,13 +48,20 @@
|
|
|
33
48
|
"dist",
|
|
34
49
|
"dashboard.html",
|
|
35
50
|
"README.md",
|
|
36
|
-
"
|
|
51
|
+
"docs/images/agent-room-dashboard.png",
|
|
52
|
+
"docs/protocol-and-reliability.md",
|
|
53
|
+
".codex/skills/ninja-p2p",
|
|
54
|
+
".agents/skills/ninja-p2p",
|
|
55
|
+
".claude/skills/ninja-p2p"
|
|
37
56
|
],
|
|
38
57
|
"sideEffects": false,
|
|
39
58
|
"scripts": {
|
|
40
59
|
"test": "tsx --test tests/**/*.test.ts",
|
|
41
60
|
"prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
42
61
|
"build": "tsc -p tsconfig.json",
|
|
62
|
+
"check": "npm test && npm run build",
|
|
63
|
+
"validate:live": "node ./scripts/validate-live.mjs",
|
|
64
|
+
"sync:docs": "node -e \"const fs=require('node:fs'); fs.mkdirSync('docs',{recursive:true}); fs.copyFileSync('dashboard.html','docs/index.html');\"",
|
|
43
65
|
"prepare": "npm run build"
|
|
44
66
|
},
|
|
45
67
|
"keywords": [
|
|
@@ -48,6 +70,10 @@
|
|
|
48
70
|
"vdo.ninja",
|
|
49
71
|
"agent",
|
|
50
72
|
"agent-mesh",
|
|
73
|
+
"cli",
|
|
74
|
+
"codex",
|
|
75
|
+
"claude-code",
|
|
76
|
+
"skill",
|
|
51
77
|
"pub-sub",
|
|
52
78
|
"data-channel",
|
|
53
79
|
"bot",
|
|
@@ -72,7 +98,7 @@
|
|
|
72
98
|
},
|
|
73
99
|
"homepage": "https://github.com/steveseguin/ninja-p2p#readme",
|
|
74
100
|
"dependencies": {
|
|
75
|
-
"@vdoninja/sdk": "^1.
|
|
101
|
+
"@vdoninja/sdk": "^1.4.0"
|
|
76
102
|
},
|
|
77
103
|
"peerDependencies": {
|
|
78
104
|
"@roamhq/wrtc": "^0.8.0"
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ninja-p2p
|
|
3
|
-
description: Use VDO.Ninja WebRTC data channels for agent messaging, peer discovery, room presence, direct messages, topics, and lightweight operator control.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# ninja-p2p
|
|
7
|
-
|
|
8
|
-
Use this when a project needs agents to meet in a room and talk to each other over WebRTC.
|
|
9
|
-
|
|
10
|
-
## What It Gives You
|
|
11
|
-
|
|
12
|
-
- `VDOBridge` for connection lifecycle and SDK wiring
|
|
13
|
-
- `PeerRegistry` for presence, skills, topics, and status
|
|
14
|
-
- `MessageBus` for room chat, direct messages, topic fanout, history replay, and the in-memory offline queue
|
|
15
|
-
- `dashboard.html` for a browser room monitor/chat client
|
|
16
|
-
|
|
17
|
-
## Install
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install @vdoninja/ninja-p2p @roamhq/wrtc
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
`@vdoninja/sdk` is installed automatically. `ws` comes from the SDK in Node.
|
|
24
|
-
|
|
25
|
-
## Default Pattern
|
|
26
|
-
|
|
27
|
-
1. Pick a shared `room`.
|
|
28
|
-
2. Give each peer a unique `streamId`.
|
|
29
|
-
3. Connect with `VDOBridge`.
|
|
30
|
-
4. Use `bridge.chat()` for room messages.
|
|
31
|
-
5. Use `bridge.chat(..., targetStreamId)` or `bridge.command()` for direct messages.
|
|
32
|
-
6. Use `bridge.publishEvent()` for topic messages.
|
|
33
|
-
7. Use `bridge.commandResponse()`, `bridge.ack()`, or `bridge.reply()` when a peer should answer back.
|
|
34
|
-
|
|
35
|
-
## Example
|
|
36
|
-
|
|
37
|
-
```ts
|
|
38
|
-
import { VDOBridge } from "@vdoninja/ninja-p2p";
|
|
39
|
-
|
|
40
|
-
const bridge = new VDOBridge({
|
|
41
|
-
room: "agents_room",
|
|
42
|
-
streamId: "planner_bot",
|
|
43
|
-
identity: {
|
|
44
|
-
streamId: "planner_bot",
|
|
45
|
-
role: "agent",
|
|
46
|
-
name: "Planner",
|
|
47
|
-
},
|
|
48
|
-
password: false,
|
|
49
|
-
skills: ["plan", "chat"],
|
|
50
|
-
topics: ["events"],
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
await bridge.connect();
|
|
54
|
-
|
|
55
|
-
bridge.chat("Planner online");
|
|
56
|
-
bridge.chat("sync now", "worker_bot");
|
|
57
|
-
bridge.publishEvent("events", "status_change", { status: "busy" });
|
|
58
|
-
|
|
59
|
-
bridge.bus.on("message:chat", (envelope) => {
|
|
60
|
-
console.log(`${envelope.from.name}: ${envelope.payload.text}`);
|
|
61
|
-
});
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## Notes
|
|
65
|
-
|
|
66
|
-
- `room` and `streamId` should be stable and human-readable.
|
|
67
|
-
- Private messages target peer `streamId`s.
|
|
68
|
-
- Topic messages are broadcast and filtered on the receiver side.
|
|
69
|
-
- The offline queue is in memory, not durable storage.
|
|
70
|
-
- `sendRaw()` and `getSDK()` are there if you need lower-level SDK access for binary data or media work.
|