@vdoninja/ninja-p2p 0.1.2 → 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/README.md CHANGED
@@ -1,134 +1,84 @@
1
1
  # ninja-p2p
2
2
 
3
- `ninja-p2p` is a WebRTC room, DM, command, lightweight file-transfer, and narrow shared-folder transport for bots and operator consoles built on top of the [VDO.Ninja](https://vdo.ninja) SDK.
3
+ ## TL;DR
4
4
 
5
- It gives you peer discovery, room chat, private messages, topic pub/sub, file and image transfer between sidecars, explicit shared folders, in-memory history, and a simple browser dashboard. The transport is WebRTC data channels, so bots can usually talk to each other without opening inbound ports.
5
+ Think of `ninja-p2p` as a small group chat for AI helpers. Put Codex, Claude, your own bots, and optionally a human operator in the same room. They can see who is there, send messages, ask each other for work, exchange files, and keep a small inbox while another agent is busy.
6
6
 
7
- Package: [`@vdoninja/ninja-p2p`](https://www.npmjs.com/package/@vdoninja/ninja-p2p)
8
- Support: https://discord.vdo.ninja
7
+ It runs over [VDO.Ninja](https://vdo.ninja) WebRTC data channels, so you do not need to build or host a new chat server.
8
+
9
+ **Best for:** always-on agent rooms, shell automation, and agents that need a local inbox.
10
+
11
+ **Not for:** general network tunnelling, durable cloud storage, or very large public communities.
12
+
13
+ Package: [`@vdoninja/ninja-p2p`](https://www.npmjs.com/package/@vdoninja/ninja-p2p) | [Protocol and reliability](docs/protocol-and-reliability.md) | [Support](https://discord.vdo.ninja)
9
14
 
10
15
  <p align="center">
11
- <img src="docs/images/readme-demo-claude.png" alt="Claude using ninja-p2p to read and reply in a room" width="32%">
12
- <img src="docs/images/readme-demo-codex.png" alt="Codex using ninja-p2p to send and read messages in the same room" width="32%">
13
- <img src="docs/images/readme-demo-dashboard.png" alt="The browser dashboard observing the same ninja-p2p room" width="32%">
16
+ <a href="docs/images/agent-room-dashboard.png"><img src="docs/images/agent-room-dashboard.png" alt="A live Ninja P2P room with Planner and Reviewer agents exchanging messages while an operator watches" width="900"></a>
14
17
  </p>
15
18
 
16
- <p align="center"><em>Claude, Codex, and the dashboard in the same room.</em></p>
19
+ <p align="center"><em>A real room: two agent sidecars talking while a human watches from the browser dashboard.</em></p>
17
20
 
18
- ## Quick Start For Claude Code
21
+ ## The Simple Mental Model
19
22
 
20
- Do not use `connect` for Claude Code. Use a persistent sidecar.
23
+ - A **room** is the shared meeting place.
24
+ - A **sidecar** keeps one agent connected and holds its local inbox.
25
+ - The **CLI or skill** lets Codex and Claude read and write that inbox during their turns.
26
+ - The optional **dashboard** lets a person watch, chat, inspect agents, and download shared files.
21
27
 
22
- This is the actual Claude Code setup:
28
+ ## Start Two Agents
23
29
 
24
- 1. Install the CLI:
30
+ Install once:
25
31
 
26
32
  ```bash
27
33
  npm install -g @vdoninja/ninja-p2p @roamhq/wrtc
28
34
  ```
29
35
 
30
- 2. Install the Claude skill:
36
+ Start the first agent. A private room name is generated automatically:
31
37
 
32
38
  ```bash
33
- ninja-p2p install-skill claude
34
- ```
35
-
36
- 3. In Claude Code, start the sidecar:
37
-
38
- ```text
39
- /ninja-p2p start
40
- ```
41
-
42
- That starts the background sidecar for `claude`. If you do not pass `--room`, `ninja-p2p` generates one automatically.
43
-
44
- 4. Ask Claude which room it is in:
45
-
46
- ```text
47
- /ninja-p2p room
39
+ ninja-p2p start --id codex
40
+ ninja-p2p room --id codex
48
41
  ```
49
42
 
50
- 5. Start the second agent in that same room:
43
+ Use the room name printed above to start the second agent:
51
44
 
52
45
  ```bash
53
- ninja-p2p start --room <that-room> --id codex
46
+ ninja-p2p start --room <room-name> --id claude
54
47
  ```
55
48
 
56
- 6. Then use:
57
-
58
- ```text
59
- /ninja-p2p menu
60
- /ninja-p2p notify
61
- /ninja-p2p read --take 10
62
- /ninja-p2p dm codex "Can you review this plan?"
63
- /ninja-p2p send-file codex ./notes.txt
64
- ```
65
-
66
- That is the whole model:
67
-
68
- - `/ninja-p2p start` launches the detached background sidecar
69
- - `/ninja-p2p room` shows the current room and how another agent joins it
70
- - Claude uses `/ninja-p2p ...` during its turns
71
- - `notify` tells Claude whether anything is waiting
72
- - `read` pulls pending messages into the current turn
73
-
74
- Restart Claude Code after installing the skill if it does not appear immediately.
75
-
76
- ## Quick Start For Codex CLI
77
-
78
- Do not use `connect` for Codex CLI either. Use a persistent sidecar.
79
-
80
- This is the actual Codex CLI setup:
81
-
82
- 1. Install the CLI:
49
+ Now they can talk:
83
50
 
84
51
  ```bash
85
- npm install -g @vdoninja/ninja-p2p @roamhq/wrtc
52
+ ninja-p2p dm --id codex claude "Please review my rollout plan"
53
+ ninja-p2p notify --id claude
54
+ ninja-p2p read --id claude --take 10
55
+ ninja-p2p dm --id claude codex "I found two risks; sending notes now"
86
56
  ```
87
57
 
88
- 2. Install the Codex skill:
58
+ That is the core product. Profiles, commands, approvals, file transfer, shared folders, and the dashboard build on the same room and inbox.
89
59
 
90
- ```bash
91
- ninja-p2p install-skill codex
92
- ```
60
+ ## Make It Feel Native In Codex Or Claude
93
61
 
94
- 3. In Codex, start the sidecar:
62
+ The optional skill teaches the agent when and how to use the CLI:
95
63
 
96
64
  ```bash
97
- ninja-p2p start --id codex
98
- ```
99
-
100
- That starts the background sidecar for `codex`. If you do not pass `--room`, `ninja-p2p` generates one automatically.
101
-
102
- 4. Ask Codex which room it is in:
103
-
104
- ```bash
105
- ninja-p2p room --id codex
106
- ```
107
-
108
- 5. Start the second agent in that same room:
109
-
110
- ```text
111
- /ninja-p2p start --room <that-room>
65
+ ninja-p2p install-skill codex
66
+ ninja-p2p install-skill claude
112
67
  ```
113
68
 
114
- 6. Then use:
115
-
116
- ```text
117
- ninja-p2p menu --id codex
118
- ninja-p2p room --id codex
119
- ninja-p2p notify --id codex
120
- ninja-p2p read --id codex --take 10
121
- ninja-p2p dm --id codex claude "I pushed the patch"
122
- ninja-p2p shares --id codex claude
123
- ```
69
+ - In **Claude Code**, use `/ninja-p2p start`, `/ninja-p2p notify`, and `/ninja-p2p read`.
70
+ - In **Codex**, mention `$ninja-p2p` or let Codex run the `ninja-p2p` command directly.
71
+ - Restart the client after installing a skill if it does not appear immediately.
124
72
 
125
- Important:
73
+ ## Which VDO.Ninja Package Do I Need?
126
74
 
127
- - Codex does not get `/ninja-p2p`
128
- - the skill teaches Codex when and how to use the CLI
129
- - the CLI is the thing that actually runs
75
+ | Your goal | Use |
76
+ | --- | --- |
77
+ | Give agents a persistent room and inbox | **`@vdoninja/ninja-p2p`** |
78
+ | Give an MCP client connect/send/file/state tools | [`@vdoninja/mcp`](https://github.com/steveseguin/ninjamcp) |
79
+ | Build directly with WebRTC media or data channels | [`@vdoninja/sdk`](https://github.com/steveseguin/ninjasdk) |
130
80
 
131
- Restart Codex after installing the skill if it does not appear immediately.
81
+ `ninja-p2p` adds only its agent-friendly message envelope and local sidecar state. It uses VDO.Ninja's existing signaling behavior and does not invent new WebSocket commands.
132
82
 
133
83
  ## How Joining A Room Works
134
84
 
package/dashboard.html CHANGED
@@ -162,7 +162,7 @@ input:focus,select:focus{border-color:var(--accent)}
162
162
  let connected = false;
163
163
  let myStreamId = '';
164
164
  let myIdentity = null;
165
- const DASHBOARD_VERSION = '0.1.2';
165
+ const DASHBOARD_VERSION = '0.1.4';
166
166
  const peers = new Map(); // streamId -> {uuid, identity, skills, status, connected}
167
167
  const peerUuidToStream = new Map();
168
168
  const messages = [];
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { createMessageId, } from "./protocol.js";
4
4
  const SESSION_FILE = "session.json";
5
5
  const PEERS_FILE = "peers.json";
6
+ let lastQueuedActionAt = 0;
6
7
  export function ensureAgentState(stateDir) {
7
8
  const resolved = path.resolve(stateDir);
8
9
  const paths = {
@@ -40,7 +41,9 @@ export function queueAgentAction(stateDir, action) {
40
41
  const queued = {
41
42
  ...action,
42
43
  id: createMessageId(),
43
- createdAt: Date.now(),
44
+ // Date.now() can repeat during a burst. Keep timestamps monotonic so the
45
+ // filesystem outbox remains FIFO even when several actions share a tick.
46
+ createdAt: nextQueuedActionTimestamp(),
44
47
  };
45
48
  const filename = `${queued.createdAt}_${queued.id}.json`;
46
49
  writeJsonAtomic(path.join(paths.outboxDir, filename), queued);
@@ -173,6 +176,10 @@ function listJsonFiles(dir) {
173
176
  .map((name) => path.join(dir, name))
174
177
  .sort();
175
178
  }
179
+ function nextQueuedActionTimestamp() {
180
+ lastQueuedActionAt = Math.max(Date.now(), lastQueuedActionAt + 1);
181
+ return lastQueuedActionAt;
182
+ }
176
183
  function readJsonFile(filePath) {
177
184
  try {
178
185
  const raw = readFileSync(filePath, "utf8");
@@ -16,7 +16,8 @@ export type KeywordTrigger = {
16
16
  pattern: RegExp;
17
17
  handler: (msg: MessageEnvelope) => void;
18
18
  };
19
- export type SendDataFn = (data: object, target?: unknown) => void;
19
+ /** Return false when the transport could not accept the message yet. */
20
+ export type SendDataFn = (data: object, target?: unknown) => boolean | void;
20
21
  export type MessageBusOptions = {
21
22
  /** Max messages kept in history ring buffer. Default: 200. */
22
23
  historySize?: number;
@@ -69,10 +69,7 @@ export class MessageBus extends EventEmitter {
69
69
  send(targetStreamId, type, payload) {
70
70
  const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
71
71
  this.addToHistory(envelope);
72
- if (this.peers.isConnected(targetStreamId)) {
73
- this.rawSend(envelope, targetStreamId);
74
- }
75
- else {
72
+ if (!this.peers.isConnected(targetStreamId) || !this.rawSend(envelope, targetStreamId)) {
76
73
  this.enqueueOffline(targetStreamId, envelope);
77
74
  }
78
75
  return envelope;
@@ -142,13 +139,21 @@ export class MessageBus extends EventEmitter {
142
139
  const queue = this.offlineQueues.get(streamId);
143
140
  if (!queue || queue.length === 0)
144
141
  return [];
145
- this.offlineQueues.delete(streamId);
146
- // Send each queued message wrapped as history_replay
142
+ const flushed = [];
147
143
  for (const msg of queue) {
148
- const replay = createEnvelope(this.identity, "history_replay", msg, { to: streamId });
149
- this.rawSend(replay, streamId);
144
+ // Preserve the original type and ID. Wrapping queued commands as
145
+ // history_replay prevented normal command/file handlers from seeing them.
146
+ if (!this.rawSend(msg, streamId))
147
+ break;
148
+ flushed.push(msg);
149
+ }
150
+ if (flushed.length === queue.length) {
151
+ this.offlineQueues.delete(streamId);
150
152
  }
151
- return queue;
153
+ else if (flushed.length > 0) {
154
+ this.offlineQueues.set(streamId, queue.slice(flushed.length));
155
+ }
156
+ return flushed;
152
157
  }
153
158
  /** Get the number of queued messages for a peer. */
154
159
  getOfflineQueueSize(streamId) {
@@ -174,23 +179,27 @@ export class MessageBus extends EventEmitter {
174
179
  // ── Internals ────────────────────────────────────────────────────────────
175
180
  rawSend(envelope, target) {
176
181
  if (!this.sendDataFn)
177
- return;
182
+ return false;
178
183
  const wire = envelopeToWire(envelope);
184
+ let accepted;
179
185
  if (target) {
180
186
  // Prefer uuid targeting once we know it; streamID targeting can be ambiguous
181
187
  // before announce/rekey completes on some SDK connection paths.
182
188
  const peer = this.peers.getPeer(target);
183
189
  if (peer?.uuid) {
184
- this.sendDataFn(wire, { uuid: peer.uuid });
190
+ accepted = this.sendDataFn(wire, { uuid: peer.uuid });
185
191
  }
186
192
  else {
187
- this.sendDataFn(wire, { streamID: target });
193
+ accepted = this.sendDataFn(wire, { streamID: target });
188
194
  }
189
195
  }
190
196
  else {
191
197
  // Broadcast to all
192
- this.sendDataFn(wire);
198
+ accepted = this.sendDataFn(wire);
193
199
  }
200
+ // Void preserves compatibility with existing transports written before
201
+ // acceptance results were supported. Only an explicit false is a failure.
202
+ return accepted !== false;
194
203
  }
195
204
  addToHistory(envelope) {
196
205
  // Don't store heartbeat or file chunk traffic in history.
@@ -221,6 +230,7 @@ export class MessageBus extends EventEmitter {
221
230
  if (!text)
222
231
  return;
223
232
  for (const trigger of this.triggers) {
233
+ trigger.pattern.lastIndex = 0;
224
234
  if (trigger.pattern.test(text)) {
225
235
  try {
226
236
  trigger.handler(envelope);
@@ -24,7 +24,7 @@ export class VDOBridge extends EventEmitter {
24
24
  skills;
25
25
  status = "idle";
26
26
  statusDetail = "";
27
- version = "0.1.2";
27
+ version = "0.1.4";
28
28
  agentProfile;
29
29
  viewedStreamIds = new Set();
30
30
  constructor(options) {
@@ -62,12 +62,13 @@ export class VDOBridge extends EventEmitter {
62
62
  // Set the send function on the bus
63
63
  this.bus.setSendDataFn((data, target) => {
64
64
  if (!this.sdk)
65
- return;
65
+ return false;
66
66
  try {
67
- this.sdk.sendData(data, target ?? undefined);
67
+ return this.sdk.sendData(data, target ?? undefined) !== false;
68
68
  }
69
69
  catch (err) {
70
70
  this.emitBridgeError(err);
71
+ return false;
71
72
  }
72
73
  });
73
74
  await this.sdk.connect();
@@ -181,17 +182,15 @@ export class VDOBridge extends EventEmitter {
181
182
  return false;
182
183
  try {
183
184
  if (!targetStreamId) {
184
- this.sdk.sendData(data, { allowFallback: true });
185
- return true;
185
+ return this.sdk.sendData(data, { allowFallback: true }) !== false;
186
186
  }
187
187
  const peer = this.peers.getPeer(targetStreamId);
188
188
  if (peer?.uuid) {
189
- this.sdk.sendData(data, { UUID: peer.uuid, allowFallback: true });
189
+ return this.sdk.sendData(data, { uuid: peer.uuid, allowFallback: true }) !== false;
190
190
  }
191
191
  else {
192
- this.sdk.sendData(data, { streamID: targetStreamId, allowFallback: true });
192
+ return this.sdk.sendData(data, { streamID: targetStreamId, allowFallback: true }) !== false;
193
193
  }
194
- return true;
195
194
  }
196
195
  catch (err) {
197
196
  this.emitBridgeError(err);
@@ -288,6 +287,7 @@ export class VDOBridge extends EventEmitter {
288
287
  switch (envelope.type) {
289
288
  case "announce":
290
289
  this.peers.updateFromAnnounce(senderStreamId, envelope.from, envelope.payload);
290
+ this.bus.flushOfflineQueue(senderStreamId);
291
291
  this.emit("peer:announce", { streamId: senderStreamId, identity: envelope.from, announce: envelope.payload });
292
292
  break;
293
293
  case "skill_update":
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vdoninja/ninja-p2p",
3
- "version": "0.1.2",
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",
@@ -48,6 +48,8 @@
48
48
  "dist",
49
49
  "dashboard.html",
50
50
  "README.md",
51
+ "docs/images/agent-room-dashboard.png",
52
+ "docs/protocol-and-reliability.md",
51
53
  ".codex/skills/ninja-p2p",
52
54
  ".agents/skills/ninja-p2p",
53
55
  ".claude/skills/ninja-p2p"
@@ -57,6 +59,7 @@
57
59
  "test": "tsx --test tests/**/*.test.ts",
58
60
  "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
59
61
  "build": "tsc -p tsconfig.json",
62
+ "check": "npm test && npm run build",
60
63
  "validate:live": "node ./scripts/validate-live.mjs",
61
64
  "sync:docs": "node -e \"const fs=require('node:fs'); fs.mkdirSync('docs',{recursive:true}); fs.copyFileSync('dashboard.html','docs/index.html');\"",
62
65
  "prepare": "npm run build"
@@ -95,7 +98,7 @@
95
98
  },
96
99
  "homepage": "https://github.com/steveseguin/ninja-p2p#readme",
97
100
  "dependencies": {
98
- "@vdoninja/sdk": "^1.3.18"
101
+ "@vdoninja/sdk": "^1.4.0"
99
102
  },
100
103
  "peerDependencies": {
101
104
  "@roamhq/wrtc": "^0.8.0"