@vdoninja/ninja-p2p 0.1.2 → 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.
Files changed (47) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -825
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +17 -1
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +17 -1
  21. package/dist/message-bus.js +55 -16
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +280 -30
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/images/agent-room-dashboard.png +0 -0
  43. package/docs/protocol-and-reliability.md +231 -0
  44. package/docs/sdk-wishlist.md +236 -0
  45. package/docs/security.md +197 -0
  46. package/docs/social-stream-bridge.md +390 -0
  47. package/package.json +125 -113
package/dist/cli-lib.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type AgentProfile } from "./protocol.js";
2
2
  import { type SharedFolderConfig } from "./shared-folders.js";
3
+ import { type WakeConfig } from "./wake.js";
3
4
  export type CliCommonOptions = {
4
5
  room: string;
5
6
  streamId: string;
@@ -10,6 +11,7 @@ export type CliCommonOptions = {
10
11
  stateDir: string | null;
11
12
  agentProfile?: AgentProfile;
12
13
  sharedFolders: SharedFolderConfig[];
14
+ wake: WakeConfig | null;
13
15
  };
14
16
  export type SkillRuntime = "claude" | "codex";
15
17
  export type CliCommand = {
@@ -43,6 +45,42 @@ export type CliCommand = {
43
45
  stateDir: string;
44
46
  take: number;
45
47
  peek: boolean;
48
+ } | {
49
+ kind: "wait";
50
+ stateDir: string;
51
+ timeoutMs: number;
52
+ intervalMs: number;
53
+ } | {
54
+ kind: "doctor";
55
+ stateRoot: string;
56
+ } | {
57
+ kind: "seed";
58
+ options: CliCommonOptions;
59
+ filePath: string;
60
+ chunkSize: number;
61
+ } | {
62
+ kind: "fetch";
63
+ options: CliCommonOptions;
64
+ query: string;
65
+ outDir: string | null;
66
+ keepSeeding: boolean;
67
+ timeoutMs: number;
68
+ } | {
69
+ kind: "demo";
70
+ room: string | null;
71
+ password: string | false;
72
+ timeoutMs: number;
73
+ keep: boolean;
74
+ } | {
75
+ kind: "ssn";
76
+ options: CliCommonOptions;
77
+ session: string;
78
+ topic: string;
79
+ host: string;
80
+ inChannel: number;
81
+ outChannel: number;
82
+ echo: boolean;
83
+ readOnly: boolean;
46
84
  } | {
47
85
  kind: "connect";
48
86
  options: CliCommonOptions;
@@ -130,3 +168,5 @@ export declare function parseJsonMaybe(value: string): unknown;
130
168
  export declare function getSkillInstallTarget(runtime: SkillRuntime, env?: NodeJS.ProcessEnv): string;
131
169
  export declare function getSkillInstallTargets(runtime: SkillRuntime, env?: NodeJS.ProcessEnv): string[];
132
170
  export declare function getDefaultStateDir(streamId: string, env?: NodeJS.ProcessEnv): string;
171
+ /** Parent folder holding every sidecar's state, used by `doctor`. */
172
+ export declare function getStateRoot(env?: NodeJS.ProcessEnv): string;
package/dist/cli-lib.js CHANGED
@@ -1,15 +1,41 @@
1
1
  import path from "node:path";
2
2
  import { createMessageId, generateRoomName } from "./protocol.js";
3
3
  import { parseSharedFolderSpecs, toSharedFolderSummaries } from "./shared-folders.js";
4
+ import { DEFAULT_WAKE_DEBOUNCE_MS, DEFAULT_WAKE_LIMIT_PER_MINUTE, } from "./wake.js";
5
+ import { DEFAULT_SSN_HOST, DEFAULT_SSN_IN_CHANNEL, DEFAULT_SSN_OUT_CHANNEL, DEFAULT_SSN_TOPIC, } from "./social-stream.js";
6
+ import { DEFAULT_SWARM_CHUNK_SIZE } from "./swarm.js";
4
7
  export function helpText() {
5
8
  return [
6
9
  "ninja-p2p",
10
+ "A shared room and handoff layer that lets separate AI tools work as a team.",
7
11
  "",
8
12
  "Start here:",
9
13
  " ninja-p2p menu",
14
+ " ninja-p2p demo",
10
15
  " ninja-p2p start --id claude",
11
16
  " ninja-p2p start --id codex",
12
17
  "",
18
+ "Troubleshooting:",
19
+ " ninja-p2p doctor",
20
+ "",
21
+ "Swarm file transfer (every downloader becomes a source):",
22
+ " ninja-p2p seed ./big-file.zip --room my-room",
23
+ " ninja-p2p fetch big-file.zip --room my-room --out ./downloads",
24
+ " ninja-p2p fetch <file-id> --room my-room --seed",
25
+ "",
26
+ " --seed keep serving after the download finishes",
27
+ " --out <dir> where finished files land",
28
+ " --chunk-size <n> bytes per chunk (default 64000)",
29
+ "",
30
+ "Live chat bridge (Social Stream Ninja):",
31
+ " ninja-p2p ssn --session <id> --room ai-room --read-only",
32
+ " ninja-p2p ssn --session <ssn-session-id> --room ai-room",
33
+ " ninja-p2p ssn --session <id> --room ai-room --topic social --read-only --echo",
34
+ "",
35
+ " Publishes Twitch/YouTube/Kick chat into the room as shared team input.",
36
+ " Start read-only. Without it, an agent can reply to every platform at once:",
37
+ " ninja-p2p command --id claude social say '{\"text\":\"hi chat\"}'",
38
+ "",
13
39
  "Install:",
14
40
  " npm install -g @vdoninja/ninja-p2p @roamhq/wrtc",
15
41
  " ninja-p2p install-skill codex",
@@ -37,6 +63,20 @@ export function helpText() {
37
63
  " ninja-p2p command --id codex claude capabilities",
38
64
  " ninja-p2p stop --id codex",
39
65
  "",
66
+ "Wake on incoming messages (agents act without a human turn):",
67
+ " ninja-p2p start --id claude --on-message \"claude -p 'Check your ninja-p2p inbox'\"",
68
+ " ninja-p2p start --id codex --on-message \"codex exec 'Check your ninja-p2p inbox'\"",
69
+ " ninja-p2p wait --id codex && echo \"mail arrived\"",
70
+ " while ninja-p2p wait --id codex; do codex exec 'handle inbox'; done",
71
+ "",
72
+ " --on-message <cmd> shell command to run when messages arrive",
73
+ " --wake-debounce <ms> batch window before firing (default 750)",
74
+ " --wake-limit <n> max wakes per minute, 0 = unlimited (default 30)",
75
+ " --timeout <ms> wait: give up after this long, 0 = forever",
76
+ "",
77
+ " The wake command receives NINJA_ID, NINJA_STATE_DIR, NINJA_WAKE_COUNT,",
78
+ " NINJA_WAKE_FROM, NINJA_WAKE_TYPES, and NINJA_WAKE_TEXT.",
79
+ "",
40
80
  "Use:",
41
81
  " ninja-p2p connect --room my-room --name Claude",
42
82
  " ninja-p2p chat --room my-room --name Steve \"hello\"",
@@ -86,6 +126,24 @@ export function parseCliArgs(argv, env = process.env) {
86
126
  },
87
127
  };
88
128
  }
129
+ if (kind === "demo") {
130
+ const parsedDemo = parseOptions(args, env);
131
+ const passwordValue = getSingleValue(parsedDemo.values, "password") ?? env.NINJA_PASSWORD;
132
+ return {
133
+ kind,
134
+ room: getSingleValue(parsedDemo.values, "room") || null,
135
+ password: passwordValue === "false" ? false : (passwordValue || false),
136
+ timeoutMs: parsePositiveInt(getSingleValue(parsedDemo.values, "timeout"), 20_000),
137
+ keep: parsedDemo.flags.has("keep"),
138
+ };
139
+ }
140
+ if (kind === "doctor") {
141
+ const parsedDoctor = parseOptions(args, env);
142
+ return {
143
+ kind,
144
+ stateRoot: getSingleValue(parsedDoctor.values, "state-root") || getStateRoot(env),
145
+ };
146
+ }
89
147
  if (kind === "install-skill") {
90
148
  const runtime = (args.shift() ?? "").toLowerCase();
91
149
  if (runtime !== "codex" && runtime !== "claude") {
@@ -133,6 +191,77 @@ export function parseCliArgs(argv, env = process.env) {
133
191
  take: parsePositiveInt(getSingleValue(parsed.values, "take"), 20),
134
192
  peek: getSingleValue(parsed.values, "peek") === "true",
135
193
  };
194
+ case "wait":
195
+ return {
196
+ kind,
197
+ stateDir: resolveStateDir(parsed.values, env),
198
+ // 0 means wait indefinitely, which is what a `while` loop wants.
199
+ timeoutMs: parseNonNegativeInt(getSingleValue(parsed.values, "timeout"), 0),
200
+ intervalMs: parsePositiveInt(getSingleValue(parsed.values, "interval"), 500),
201
+ };
202
+ case "seed":
203
+ {
204
+ if (positional.length < 1) {
205
+ throw new Error("seed requires a file path");
206
+ }
207
+ const options = buildCommonOptions(parsed.values, env, {
208
+ requireRoom: false,
209
+ requireStateDir: false,
210
+ allowGeneratedRoom: true,
211
+ });
212
+ return {
213
+ kind,
214
+ options,
215
+ filePath: positional.join(" "),
216
+ chunkSize: parsePositiveInt(getSingleValue(parsed.values, "chunk-size"), DEFAULT_SWARM_CHUNK_SIZE),
217
+ };
218
+ }
219
+ case "fetch":
220
+ {
221
+ if (positional.length < 1) {
222
+ throw new Error("fetch requires a file id or name");
223
+ }
224
+ const options = buildCommonOptions(parsed.values, env, {
225
+ requireRoom: true,
226
+ requireStateDir: false,
227
+ });
228
+ return {
229
+ kind,
230
+ options,
231
+ query: positional.join(" "),
232
+ outDir: getSingleValue(parsed.values, "out") ?? null,
233
+ keepSeeding: parsed.flags.has("seed"),
234
+ timeoutMs: parseNonNegativeInt(getSingleValue(parsed.values, "timeout"), 300_000),
235
+ };
236
+ }
237
+ case "ssn":
238
+ {
239
+ const session = getSingleValue(parsed.values, "session") || env.SSN_SESSION || positional[0];
240
+ if (!session) {
241
+ throw new Error("ssn requires a Social Stream Ninja session id; use --session <id>");
242
+ }
243
+ const options = buildCommonOptions(parsed.values, env, {
244
+ requireRoom: false,
245
+ requireStateDir: false,
246
+ allowGeneratedRoom: true,
247
+ });
248
+ return {
249
+ kind,
250
+ options: {
251
+ ...options,
252
+ name: getSingleValue(parsed.values, "name") || env.NINJA_NAME || "Social Stream",
253
+ streamId: getSingleValue(parsed.values, "id") || env.NINJA_ID || "social",
254
+ role: getSingleValue(parsed.values, "role") || env.NINJA_ROLE || "bridge",
255
+ },
256
+ session,
257
+ topic: getSingleValue(parsed.values, "topic") || DEFAULT_SSN_TOPIC,
258
+ host: getSingleValue(parsed.values, "ssn-host") || env.SSN_HOST || DEFAULT_SSN_HOST,
259
+ inChannel: parseNonNegativeInt(getSingleValue(parsed.values, "in-channel"), DEFAULT_SSN_IN_CHANNEL),
260
+ outChannel: parseNonNegativeInt(getSingleValue(parsed.values, "out-channel"), DEFAULT_SSN_OUT_CHANNEL),
261
+ echo: parsed.flags.has("echo"),
262
+ readOnly: parsed.flags.has("read-only"),
263
+ };
264
+ }
136
265
  case "connect":
137
266
  return { kind, options: buildCommonOptions(parsed.values, env, { requireRoom: false, requireStateDir: false, allowGeneratedRoom: true }) };
138
267
  case "chat":
@@ -402,15 +531,22 @@ export function getSkillInstallTargets(runtime, env = process.env) {
402
531
  return [primary];
403
532
  }
404
533
  export function getDefaultStateDir(streamId, env = process.env) {
534
+ return path.join(getStateRoot(env), streamId);
535
+ }
536
+ /** Parent folder holding every sidecar's state, used by `doctor`. */
537
+ export function getStateRoot(env = process.env) {
405
538
  const home = env.USERPROFILE || env.HOME;
406
539
  if (!home) {
407
540
  throw new Error("cannot determine home directory");
408
541
  }
409
- return path.join(home, ".ninja-p2p", streamId);
542
+ return path.join(home, ".ninja-p2p");
410
543
  }
544
+ /** Flags that stand alone. Everything else still requires an explicit value. */
545
+ const BOOLEAN_FLAGS = new Set(["keep", "echo", "read-only", "seed"]);
411
546
  function parseOptions(argv, env) {
412
547
  const values = {};
413
548
  const positional = [];
549
+ const flags = new Set();
414
550
  for (let i = 0; i < argv.length; i += 1) {
415
551
  const arg = argv[i];
416
552
  if (!arg.startsWith("--")) {
@@ -418,6 +554,10 @@ function parseOptions(argv, env) {
418
554
  continue;
419
555
  }
420
556
  const key = arg.slice(2);
557
+ if (BOOLEAN_FLAGS.has(key)) {
558
+ flags.add(key);
559
+ continue;
560
+ }
421
561
  const next = argv[i + 1];
422
562
  if (!next || next.startsWith("--")) {
423
563
  throw new Error(`missing value for --${key}`);
@@ -448,6 +588,8 @@ function parseOptions(argv, env) {
448
588
  values["state-dir"] = env.NINJA_STATE_DIR;
449
589
  if (!values["wait-ms"] && env.NINJA_WAIT_MS)
450
590
  values["wait-ms"] = env.NINJA_WAIT_MS;
591
+ if (!values["on-message"] && env.NINJA_ON_MESSAGE)
592
+ values["on-message"] = env.NINJA_ON_MESSAGE;
451
593
  if (!values.runtime && env.NINJA_RUNTIME)
452
594
  values.runtime = env.NINJA_RUNTIME;
453
595
  if (!values.provider && env.NINJA_PROVIDER)
@@ -464,7 +606,7 @@ function parseOptions(argv, env) {
464
606
  values.ask = env.NINJA_ASKS.split(";").map((item) => item.trim()).filter(Boolean);
465
607
  if (!values.share && env.NINJA_SHARE)
466
608
  values.share = env.NINJA_SHARE.split(";").map((item) => item.trim()).filter(Boolean);
467
- return { values, positional };
609
+ return { values, positional, flags };
468
610
  }
469
611
  function buildCommonOptions(values, env, options) {
470
612
  const requireRoom = options?.requireRoom ?? true;
@@ -499,6 +641,7 @@ function buildCommonOptions(values, env, options) {
499
641
  stateDir,
500
642
  agentProfile,
501
643
  sharedFolders,
644
+ wake: buildWakeConfig(values, env),
502
645
  };
503
646
  }
504
647
  function resolveStateDir(values, env, fallbackStreamId) {
@@ -519,6 +662,26 @@ function parsePositiveInt(raw, fallback) {
519
662
  return fallback;
520
663
  return value;
521
664
  }
665
+ function parseNonNegativeInt(raw, fallback) {
666
+ if (!raw)
667
+ return fallback;
668
+ const value = Number.parseInt(raw, 10);
669
+ if (!Number.isFinite(value) || value < 0)
670
+ return fallback;
671
+ return value;
672
+ }
673
+ function buildWakeConfig(values, env) {
674
+ const command = (getSingleValue(values, "on-message") || env.NINJA_ON_MESSAGE || "").trim();
675
+ if (!command)
676
+ return null;
677
+ return {
678
+ command,
679
+ debounceMs: parseNonNegativeInt(getSingleValue(values, "wake-debounce") ?? env.NINJA_WAKE_DEBOUNCE, DEFAULT_WAKE_DEBOUNCE_MS),
680
+ // 0 disables the rate limit. Keep a default ceiling so a pair of agents
681
+ // that reply to each other cannot spin unattended forever.
682
+ limitPerMinute: parseNonNegativeInt(getSingleValue(values, "wake-limit") ?? env.NINJA_WAKE_LIMIT, DEFAULT_WAKE_LIMIT_PER_MINUTE),
683
+ };
684
+ }
522
685
  function shouldUseStateMode(values, env) {
523
686
  if (getSingleValue(values, "state-dir") || env.NINJA_STATE_DIR)
524
687
  return true;
package/dist/cli.d.ts CHANGED
@@ -23,3 +23,5 @@ export declare function buildSidecarCommandResponse(bridge: VDOBridge, stateDir:
23
23
  export declare function maybeHandleSidecarCommand(bridge: VDOBridge, stateDir: string, envelope: MessageEnvelope): boolean;
24
24
  export declare function createPeerNoticeEnvelope(peerIdentity: PeerIdentity, kind: "peer_discovered" | "peer_updated" | "peer_left", details: Record<string, unknown>): MessageEnvelope;
25
25
  export declare function recordPeerNotice(fingerprints: Map<string, string>, peerIdentity: PeerIdentity, kind: "peer_discovered" | "peer_updated", details: Record<string, unknown>): MessageEnvelope | null;
26
+ export declare const DASHBOARD_URL = "https://steveseguin.github.io/ninja-p2p/dashboard.html";
27
+ export declare function buildDashboardUrl(room: string, password: string | false): string;