@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.
@@ -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
+ }
@@ -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
  }
@@ -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.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.emit("error", err);
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, { UUID: peer.uuid });
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.emit("error", err);
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
- // View each existing peer to establish data channels
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.emit("error", event.detail?.error);
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
  }
@@ -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.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
- "skills/ninja-p2p"
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.3.18"
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.