@vdoninja/ninja-p2p 0.1.4 → 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 (46) 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 -775
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +9 -0
  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 +15 -0
  21. package/dist/message-bus.js +32 -3
  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 +273 -23
  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/protocol-and-reliability.md +231 -38
  43. package/docs/sdk-wishlist.md +236 -0
  44. package/docs/security.md +197 -0
  45. package/docs/social-stream-bridge.md +390 -0
  46. package/package.json +125 -116
package/dist/cli.js CHANGED
@@ -1,17 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { closeSync, cpSync, existsSync, mkdirSync, openSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
7
  import readline from "node:readline";
7
8
  import { setTimeout as delay } from "node:timers/promises";
8
9
  import { fileURLToPath } from "node:url";
9
- import { appendIncomingTransferChunk, beginIncomingTransfer, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, sendFileFromPath, } from "./file-transfer.js";
10
+ import { appendIncomingTransferChunk, beginIncomingTransfer, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, discardIncomingTransfer, sendFileFromPath, } from "./file-transfer.js";
10
11
  import { VDOBridge } from "./vdo-bridge.js";
11
12
  import { createEnvelope, envelopeToWire, } from "./protocol.js";
12
13
  import { ensureAgentState, getInboxSummary, isInboxWorthy, listQueuedAgentActions, queueAgentAction, readAgentSession, readPeersSnapshot, removeQueuedAgentAction, storeInboxMessage, takeInboxMessages, writeAgentSession, writePeersSnapshot, } from "./agent-state.js";
13
14
  import { getDefaultStateDir, getSkillInstallTarget, getSkillInstallTargets, helpText, parseCliArgs, parseJsonMaybe, } from "./cli-lib.js";
14
15
  import { listSharedFolderEntries, resolveSharedFile, } from "./shared-folders.js";
16
+ import { DEFAULT_WAKE_DEBOUNCE_MS, DEFAULT_WAKE_LIMIT_PER_MINUTE, WakeRunner, } from "./wake.js";
17
+ import { formatDemoResult, runDemo } from "./demo.js";
18
+ import { describeSocialMessage, SocialStreamBridge } from "./social-stream.js";
19
+ import { SwarmManager } from "./swarm-manager.js";
20
+ import { checkNodeVersion, checkSidecars, checkSignaling, checkStateRoot, checkWebRtc, formatReport, summarizeChecks, } from "./doctor.js";
15
21
  const SIDECAR_SKILLS = ["cli", "chat", "command", "sidecar", "discovery"];
16
22
  export const SIDECAR_DISCOVERY_ASKS = [
17
23
  { name: "help", description: "Summarize this agent sidecar and its built-in commands.", via: "command", example: "ninja-p2p command --id <you> <peer> help" },
@@ -75,6 +81,24 @@ async function main(argv) {
75
81
  messages: takeInboxMessages(parsed.stateDir, parsed.take, parsed.peek).map((item) => item.envelope),
76
82
  }, null, 2));
77
83
  return;
84
+ case "wait":
85
+ await waitForInbox(parsed.stateDir, parsed.timeoutMs, parsed.intervalMs);
86
+ return;
87
+ case "doctor":
88
+ await runDoctor(parsed.stateRoot);
89
+ return;
90
+ case "demo":
91
+ await runDemoCommand(parsed.room, parsed.password, parsed.timeoutMs, parsed.keep);
92
+ return;
93
+ case "seed":
94
+ await runSeed(parsed.options, parsed.filePath, parsed.chunkSize);
95
+ return;
96
+ case "fetch":
97
+ await runFetch(parsed.options, parsed.query, parsed.outDir, parsed.keepSeeding, parsed.timeoutMs);
98
+ return;
99
+ case "ssn":
100
+ await runSocialStreamBridge(parsed);
101
+ return;
78
102
  case "connect":
79
103
  await runConnect(parsed.options);
80
104
  return;
@@ -500,6 +524,19 @@ async function runAgent(options) {
500
524
  const advertisedProfile = composeSidecarAgentProfile(options.agentProfile);
501
525
  const peerFingerprints = new Map();
502
526
  let shuttingDown = false;
527
+ // Wake hooks fire on real peer traffic only. Peer join/leave notices are
528
+ // generated locally and would otherwise wake the agent every reconnect.
529
+ const wake = options.wake
530
+ ? new WakeRunner({
531
+ config: options.wake,
532
+ context: {
533
+ streamId: options.streamId,
534
+ room: options.room,
535
+ stateDir: options.stateDir,
536
+ },
537
+ log: (message) => console.log(message),
538
+ })
539
+ : null;
503
540
  const syncSession = (connected = bridge.isConnected()) => {
504
541
  writeAgentSession(options.stateDir, {
505
542
  room: options.room,
@@ -576,6 +613,10 @@ async function runAgent(options) {
576
613
  await bridge.connect();
577
614
  syncSession(true);
578
615
  console.log(`agent ready: ${options.streamId} -> ${options.stateDir}`);
616
+ if (options.wake) {
617
+ console.log(`wake on message: ${options.wake.command}`);
618
+ console.log(`wake debounce=${options.wake.debounceMs}ms limit=${options.wake.limitPerMinute || "unlimited"}/min`);
619
+ }
579
620
  bridge.on("peer:connected", () => {
580
621
  syncSession(true);
581
622
  });
@@ -638,6 +679,7 @@ async function runAgent(options) {
638
679
  const autoHandled = transferHandled || maybeHandleSidecarCommand(bridge, options.stateDir, envelope);
639
680
  if (!autoHandled && isInboxWorthy(envelope.type)) {
640
681
  storeInboxMessage(options.stateDir, envelope);
682
+ wake?.notify(envelope);
641
683
  console.log(`[inbox] ${envelope.type} from ${envelope.from.streamId}`);
642
684
  }
643
685
  else if (transferHandled) {
@@ -659,6 +701,7 @@ async function runAgent(options) {
659
701
  return;
660
702
  shuttingDown = true;
661
703
  clearInterval(heartbeatTimer);
704
+ wake?.dispose();
662
705
  syncSession(false);
663
706
  await bridge.disconnect();
664
707
  syncSession(false);
@@ -898,6 +941,7 @@ function maybeHandleSidecarFileTransfer(bridge, stateDir, envelope) {
898
941
  }
899
942
  function handleIncomingFileOffer(bridge, stateDir, envelope) {
900
943
  const payload = envelope.payload;
944
+ const transferId = transferIdForError(envelope.payload);
901
945
  try {
902
946
  beginIncomingTransfer(stateDir, envelope.from, payload);
903
947
  storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, "file_offered", {
@@ -910,9 +954,9 @@ function handleIncomingFileOffer(bridge, stateDir, envelope) {
910
954
  }));
911
955
  }
912
956
  catch (error) {
913
- bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(payload.transferId, error));
957
+ bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(transferId, error));
914
958
  storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, "file_receive_failed", {
915
- transferId: payload.transferId,
959
+ transferId,
916
960
  error: error instanceof Error ? error.message : String(error),
917
961
  }));
918
962
  }
@@ -920,13 +964,15 @@ function handleIncomingFileOffer(bridge, stateDir, envelope) {
920
964
  }
921
965
  function handleIncomingFileChunk(bridge, stateDir, envelope) {
922
966
  const payload = envelope.payload;
967
+ const transferId = transferIdForError(envelope.payload);
923
968
  try {
924
- appendIncomingTransferChunk(stateDir, payload);
969
+ appendIncomingTransferChunk(stateDir, payload, envelope.from.streamId);
925
970
  }
926
971
  catch (error) {
927
- bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(payload.transferId, error));
972
+ discardIncomingTransfer(stateDir, transferId, envelope.from.streamId);
973
+ bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(transferId, error));
928
974
  storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, "file_receive_failed", {
929
- transferId: payload.transferId,
975
+ transferId,
930
976
  error: error instanceof Error ? error.message : String(error),
931
977
  }));
932
978
  }
@@ -934,22 +980,28 @@ function handleIncomingFileChunk(bridge, stateDir, envelope) {
934
980
  }
935
981
  function handleIncomingFileComplete(bridge, stateDir, envelope) {
936
982
  const payload = envelope.payload;
983
+ const transferId = transferIdForError(envelope.payload);
937
984
  try {
938
- const completed = completeIncomingTransfer(stateDir, payload);
985
+ const completed = completeIncomingTransfer(stateDir, payload, envelope.from.streamId);
939
986
  bridge.bus.send(envelope.from.streamId, "file_ack", createFileAckPayload(completed));
940
987
  storeInboxMessage(stateDir, createReceivedFileEventEnvelope(envelope.from, completed));
941
988
  }
942
989
  catch (error) {
943
- bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(payload.transferId, error));
990
+ discardIncomingTransfer(stateDir, transferId, envelope.from.streamId);
991
+ bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(transferId, error));
944
992
  storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, "file_receive_failed", {
945
- transferId: payload.transferId,
993
+ transferId,
946
994
  error: error instanceof Error ? error.message : String(error),
947
995
  }));
948
996
  }
949
997
  return true;
950
998
  }
951
999
  function handleIncomingFileAck(stateDir, envelope) {
1000
+ if (typeof envelope.payload !== "object" || envelope.payload === null)
1001
+ return true;
952
1002
  const payload = envelope.payload;
1003
+ if (typeof payload.transferId !== "string" || typeof payload.ok !== "boolean")
1004
+ return true;
953
1005
  storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, payload.ok ? "file_delivered" : "file_delivery_failed", {
954
1006
  transferId: payload.transferId,
955
1007
  name: payload.name ?? null,
@@ -962,6 +1014,12 @@ function handleIncomingFileAck(stateDir, envelope) {
962
1014
  }));
963
1015
  return true;
964
1016
  }
1017
+ function transferIdForError(payload) {
1018
+ if (typeof payload !== "object" || payload === null)
1019
+ return "invalid";
1020
+ const transferId = payload.transferId;
1021
+ return typeof transferId === "string" && transferId ? transferId.slice(0, 128) : "invalid";
1022
+ }
965
1023
  function createReceivedFileEventEnvelope(from, completed) {
966
1024
  return createFileTransferEventEnvelope(from, "file_received", {
967
1025
  transferId: completed.transferId,
@@ -1317,8 +1375,416 @@ function buildAgentArgs(options) {
1317
1375
  for (const share of options.sharedFolders) {
1318
1376
  args.push("--share", `${share.name}=${share.path}`);
1319
1377
  }
1378
+ if (options.wake) {
1379
+ args.push("--on-message", options.wake.command);
1380
+ if (options.wake.debounceMs !== DEFAULT_WAKE_DEBOUNCE_MS) {
1381
+ args.push("--wake-debounce", String(options.wake.debounceMs));
1382
+ }
1383
+ if (options.wake.limitPerMinute !== DEFAULT_WAKE_LIMIT_PER_MINUTE) {
1384
+ args.push("--wake-limit", String(options.wake.limitPerMinute));
1385
+ }
1386
+ }
1320
1387
  return args;
1321
1388
  }
1389
+ function createSwarmBridge(options, name, role) {
1390
+ return new VDOBridge({
1391
+ room: options.room,
1392
+ streamId: options.streamId,
1393
+ identity: { streamId: options.streamId, role, name },
1394
+ password: options.password,
1395
+ skills: ["swarm", "file-transfer"],
1396
+ topics: [],
1397
+ });
1398
+ }
1399
+ function swarmWorkDir() {
1400
+ return path.join(os.tmpdir(), "ninja-p2p-swarm");
1401
+ }
1402
+ /** Publish a file to the room and serve it until interrupted. */
1403
+ async function runSeed(options, filePath, chunkSize) {
1404
+ const bridge = createSwarmBridge(options, options.name || "Seeder", "seeder");
1405
+ const manager = new SwarmManager({
1406
+ bridge,
1407
+ downloadDir: path.resolve("."),
1408
+ workDir: swarmWorkDir(),
1409
+ log: (message) => console.log(message),
1410
+ });
1411
+ await bridge.connect();
1412
+ manager.start();
1413
+ const manifest = manager.seed(filePath, chunkSize);
1414
+ console.log("");
1415
+ console.log(`seeding ${manifest.name}`);
1416
+ console.log(` room: ${options.room}`);
1417
+ console.log(` id: ${options.streamId}`);
1418
+ console.log(` file id: ${manifest.fileId}`);
1419
+ console.log(` size: ${formatBytes(manifest.size)} in ${manifest.totalChunks} chunk(s)`);
1420
+ console.log("");
1421
+ console.log("Fetch it from anywhere with:");
1422
+ console.log(` ninja-p2p fetch ${manifest.name} --room ${options.room}`);
1423
+ console.log("");
1424
+ console.log("Anyone who finishes also becomes a source. Press Ctrl+C to stop.");
1425
+ let shuttingDown = false;
1426
+ const shutdown = async () => {
1427
+ if (shuttingDown)
1428
+ return;
1429
+ shuttingDown = true;
1430
+ manager.stop();
1431
+ await bridge.disconnect();
1432
+ };
1433
+ process.on("SIGINT", () => { void shutdown(); });
1434
+ process.on("SIGTERM", () => { void shutdown(); });
1435
+ await new Promise((resolve) => {
1436
+ bridge.once("disconnected", () => resolve());
1437
+ });
1438
+ }
1439
+ /** Download a file from whoever in the room has it. */
1440
+ async function runFetch(options, query, outDir, keepSeeding, timeoutMs) {
1441
+ const downloadDir = path.resolve(outDir ?? ".");
1442
+ const bridge = createSwarmBridge(options, options.name || "Fetcher", "fetcher");
1443
+ let done = null;
1444
+ let failed = null;
1445
+ const finished = new Promise((resolve) => { done = resolve; });
1446
+ const manager = new SwarmManager({
1447
+ bridge,
1448
+ downloadDir,
1449
+ workDir: swarmWorkDir(),
1450
+ log: (message) => console.log(message),
1451
+ onComplete: (completion) => done?.(completion),
1452
+ onError: (message) => {
1453
+ failed = message;
1454
+ done?.(null);
1455
+ },
1456
+ });
1457
+ await bridge.connect();
1458
+ manager.start();
1459
+ console.log(`joined ${options.room} as ${options.streamId}, looking for "${query}"`);
1460
+ // Offers arrive on announce, so give the room a moment before giving up.
1461
+ const deadline = timeoutMs > 0
1462
+ ? Date.now() + Math.min(timeoutMs, 60_000)
1463
+ : Number.POSITIVE_INFINITY;
1464
+ let manifest = manager.resolveOffer(query);
1465
+ while (!manifest && Date.now() < deadline) {
1466
+ await delay(500);
1467
+ manifest = manager.resolveOffer(query);
1468
+ }
1469
+ if (!manifest) {
1470
+ const offers = manager.knownOffers();
1471
+ console.error(`no offer matching "${query}" in room ${options.room}`);
1472
+ if (offers.length > 0) {
1473
+ console.error("available:");
1474
+ for (const offer of offers) {
1475
+ console.error(` ${offer.name} ${offer.fileId.slice(0, 16)} ${formatBytes(offer.size)}`);
1476
+ }
1477
+ }
1478
+ manager.stop();
1479
+ await bridge.disconnect();
1480
+ process.exitCode = 1;
1481
+ return;
1482
+ }
1483
+ console.log(`fetching ${manifest.name} (${formatBytes(manifest.size)}, ${manifest.totalChunks} chunks)`);
1484
+ const startedAt = Date.now();
1485
+ const accepted = manager.fetch(manifest.fileId);
1486
+ if (!accepted && failed) {
1487
+ console.error(failed);
1488
+ manager.stop();
1489
+ await bridge.disconnect();
1490
+ process.exitCode = 1;
1491
+ return;
1492
+ }
1493
+ const progressTimer = setInterval(() => {
1494
+ const progress = manager.sessionFor(manifest.fileId)?.progress();
1495
+ if (progress && !progress.complete) {
1496
+ console.log(` ${progress.percent}% ${progress.haveChunks}/${progress.totalChunks} chunks ${progress.peers} peer(s) ${progress.inFlight} in flight`);
1497
+ }
1498
+ }, 2_000);
1499
+ progressTimer.unref?.();
1500
+ const timeoutTimer = timeoutMs > 0 ? setTimeout(() => done?.(null), timeoutMs) : null;
1501
+ timeoutTimer?.unref?.();
1502
+ const completion = await finished;
1503
+ clearInterval(progressTimer);
1504
+ if (timeoutTimer)
1505
+ clearTimeout(timeoutTimer);
1506
+ if (!completion) {
1507
+ const progress = manager.sessionFor(manifest.fileId)?.progress();
1508
+ if (failed) {
1509
+ console.error(failed);
1510
+ }
1511
+ else {
1512
+ console.error(`timed out after ${formatDuration(Date.now() - startedAt)} at ${progress?.percent ?? 0}%`);
1513
+ }
1514
+ manager.stop();
1515
+ await bridge.disconnect();
1516
+ process.exitCode = 1;
1517
+ return;
1518
+ }
1519
+ const elapsed = Date.now() - startedAt;
1520
+ console.log("");
1521
+ console.log(`saved ${completion.savedPath}`);
1522
+ console.log(` ${formatBytes(completion.manifest.size)} in ${formatDuration(elapsed)} (${formatRate(completion.manifest.size, elapsed)})`);
1523
+ if (keepSeeding) {
1524
+ console.log("");
1525
+ console.log("Still seeding for other peers. Press Ctrl+C to stop.");
1526
+ let shuttingDown = false;
1527
+ const shutdown = async () => {
1528
+ if (shuttingDown)
1529
+ return;
1530
+ shuttingDown = true;
1531
+ manager.stop();
1532
+ await bridge.disconnect();
1533
+ };
1534
+ process.on("SIGINT", () => { void shutdown(); });
1535
+ process.on("SIGTERM", () => { void shutdown(); });
1536
+ await new Promise((resolve) => {
1537
+ bridge.once("disconnected", () => resolve());
1538
+ });
1539
+ return;
1540
+ }
1541
+ manager.stop();
1542
+ await bridge.disconnect();
1543
+ }
1544
+ function formatBytes(size) {
1545
+ if (size < 1024)
1546
+ return `${size} B`;
1547
+ if (size < 1024 * 1024)
1548
+ return `${(size / 1024).toFixed(1)} KB`;
1549
+ if (size < 1024 * 1024 * 1024)
1550
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
1551
+ return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`;
1552
+ }
1553
+ function formatDuration(ms) {
1554
+ if (ms < 1000)
1555
+ return `${ms}ms`;
1556
+ return `${(ms / 1000).toFixed(1)}s`;
1557
+ }
1558
+ function formatRate(bytes, ms) {
1559
+ if (ms <= 0)
1560
+ return "n/a";
1561
+ return `${formatBytes(Math.round((bytes * 1000) / ms))}/s`;
1562
+ }
1563
+ /**
1564
+ * Bridge Social Stream Ninja's live chat into a ninja-p2p room.
1565
+ *
1566
+ * Inbound: every chat message SSN aggregates becomes a topic event, so agents
1567
+ * can watch a stream's chat the same way they watch any other room traffic.
1568
+ *
1569
+ * Outbound: the bridge advertises a `say` command, so an agent that wants to
1570
+ * answer chat sends one message and SSN fans it out to every connected
1571
+ * platform. That is the whole co-host loop in two directions.
1572
+ */
1573
+ async function runSocialStreamBridge(parsed) {
1574
+ const { options } = parsed;
1575
+ // An SSN session id grants everything: send chat, block users, clear
1576
+ // overlays, fake donations. Until SSN can issue read-scoped credentials, this
1577
+ // is the next best thing — the bridge structurally refuses to speak, so a
1578
+ // compromised or over-eager agent in the room cannot reach the audience.
1579
+ const readOnly = parsed.readOnly;
1580
+ const bridge = new VDOBridge({
1581
+ room: options.room,
1582
+ streamId: options.streamId,
1583
+ identity: {
1584
+ streamId: options.streamId,
1585
+ role: options.role,
1586
+ name: options.name,
1587
+ },
1588
+ password: options.password,
1589
+ skills: ["social", "chat", "command", "bridge"],
1590
+ topics: [parsed.topic, "events"],
1591
+ agentProfile: {
1592
+ ...options.agentProfile,
1593
+ summary: options.agentProfile?.summary
1594
+ ?? (readOnly
1595
+ ? "Read-only bridge for live stream chat from Social Stream Ninja."
1596
+ : "Bridges live stream chat from Social Stream Ninja into this room."),
1597
+ can: [...new Set([
1598
+ ...(options.agentProfile?.can ?? []),
1599
+ "social-chat",
1600
+ ...(readOnly ? [] : ["broadcast"]),
1601
+ ])],
1602
+ asks: [
1603
+ ...(options.agentProfile?.asks ?? []),
1604
+ // Do not advertise an ability we will refuse to perform.
1605
+ ...(readOnly ? [] : [{
1606
+ name: "say",
1607
+ description: "Send a message out to every live chat platform connected to Social Stream Ninja.",
1608
+ via: "command",
1609
+ example: `ninja-p2p command --id <you> ${options.streamId} say '{"text":"hello chat"}'`,
1610
+ }]),
1611
+ ],
1612
+ },
1613
+ });
1614
+ let forwarded = 0;
1615
+ const social = new SocialStreamBridge({
1616
+ session: parsed.session,
1617
+ host: parsed.host,
1618
+ inChannel: parsed.inChannel,
1619
+ outChannel: parsed.outChannel,
1620
+ log: (message) => console.log(message),
1621
+ onMessage: (message) => {
1622
+ forwarded += 1;
1623
+ bridge.publishEvent(parsed.topic, "social_chat", message);
1624
+ if (parsed.echo) {
1625
+ console.log(describeSocialMessage(message));
1626
+ }
1627
+ },
1628
+ });
1629
+ bridge.bus.on("message:command", (envelope) => {
1630
+ const payload = envelope.payload;
1631
+ if (payload.command !== "say")
1632
+ return;
1633
+ if (readOnly) {
1634
+ // Refuse loudly. Silently dropping it would let an agent believe it spoke
1635
+ // to the audience when nothing was said.
1636
+ bridge.commandResponse(envelope, undefined, "bridge is running read-only; restart without --read-only to allow sending");
1637
+ console.log(`[ssn] refused say from ${envelope.from.streamId} (read-only)`);
1638
+ return;
1639
+ }
1640
+ const text = extractSayText(payload.args);
1641
+ if (!text) {
1642
+ bridge.commandResponse(envelope, undefined, "say requires text, e.g. '{\"text\":\"hello chat\"}'");
1643
+ return;
1644
+ }
1645
+ // Report the truth: if the relay is down the message did not reach chat.
1646
+ if (!social.isConnected()) {
1647
+ bridge.commandResponse(envelope, undefined, "not connected to Social Stream Ninja");
1648
+ return;
1649
+ }
1650
+ const sent = social.sendChat(text);
1651
+ bridge.commandResponse(envelope, sent ? { sent: true, text } : undefined, sent ? undefined : "failed to send to Social Stream Ninja");
1652
+ console.log(`[ssn] ${envelope.from.streamId} -> chat: ${text}`);
1653
+ });
1654
+ await bridge.connect();
1655
+ social.connect();
1656
+ console.log(`social stream bridge ready`);
1657
+ console.log(` room: ${options.room}`);
1658
+ console.log(` id: ${options.streamId}`);
1659
+ console.log(` session: ${parsed.session}`);
1660
+ console.log(` topic: ${parsed.topic}`);
1661
+ console.log("");
1662
+ if (readOnly) {
1663
+ console.log("Read-only: agents can watch chat but cannot send to it.");
1664
+ }
1665
+ else {
1666
+ console.log("Agents in this room can reply to every platform with:");
1667
+ console.log(` ninja-p2p command --id <you> ${options.streamId} say '{"text":"hello chat"}'`);
1668
+ console.log("");
1669
+ console.log("Anything an agent says here reaches your real audience.");
1670
+ console.log("Use --read-only if you only want them to watch.");
1671
+ }
1672
+ console.log("");
1673
+ console.log("Press Ctrl+C to stop.");
1674
+ let shuttingDown = false;
1675
+ const shutdown = async () => {
1676
+ if (shuttingDown)
1677
+ return;
1678
+ shuttingDown = true;
1679
+ console.log(`\nforwarded ${forwarded} chat message(s)`);
1680
+ social.close();
1681
+ await bridge.disconnect();
1682
+ };
1683
+ process.on("SIGINT", () => { void shutdown(); });
1684
+ process.on("SIGTERM", () => { void shutdown(); });
1685
+ await new Promise((resolve) => {
1686
+ bridge.once("disconnected", () => resolve());
1687
+ });
1688
+ }
1689
+ function extractSayText(args) {
1690
+ if (typeof args === "string")
1691
+ return args.trim();
1692
+ if (typeof args === "object" && args !== null) {
1693
+ const value = args.text;
1694
+ if (typeof value === "string")
1695
+ return value.trim();
1696
+ }
1697
+ return "";
1698
+ }
1699
+ export const DASHBOARD_URL = "https://steveseguin.github.io/ninja-p2p/dashboard.html";
1700
+ export function buildDashboardUrl(room, password) {
1701
+ const params = new URLSearchParams({
1702
+ room,
1703
+ password: password === false ? "false" : password,
1704
+ name: "You",
1705
+ autoconnect: "true",
1706
+ });
1707
+ return `${DASHBOARD_URL}?${params.toString()}`;
1708
+ }
1709
+ /** One-command proof that the transport works on this machine and network. */
1710
+ async function runDemoCommand(room, password, timeoutMs, keep) {
1711
+ console.log("ninja-p2p demo");
1712
+ console.log("");
1713
+ console.log("Two peers, no server of your own. Watch the round trip:");
1714
+ console.log("");
1715
+ const result = await runDemo({
1716
+ room: room ?? undefined,
1717
+ password,
1718
+ timeoutMs,
1719
+ log: (message) => console.log(message),
1720
+ hold: keep
1721
+ ? async (activeRoom) => {
1722
+ console.log("");
1723
+ console.log("Both peers are still in the room. Open this to watch:");
1724
+ console.log(` ${buildDashboardUrl(activeRoom, password)}`);
1725
+ console.log("");
1726
+ console.log("Press Ctrl+C to stop.");
1727
+ await new Promise((resolve) => {
1728
+ process.on("SIGINT", () => resolve());
1729
+ process.on("SIGTERM", () => resolve());
1730
+ });
1731
+ }
1732
+ : undefined,
1733
+ });
1734
+ console.log(formatDemoResult(result, buildDashboardUrl(result.room, password)));
1735
+ if (!result.ok) {
1736
+ process.exitCode = 1;
1737
+ }
1738
+ }
1739
+ /** Run every diagnostic and exit non-zero if a required one failed. */
1740
+ async function runDoctor(stateRoot) {
1741
+ const checks = [checkNodeVersion(process.version)];
1742
+ checks.push(await checkWebRtc());
1743
+ checks.push(await checkSignaling());
1744
+ checks.push(checkStateRoot(stateRoot));
1745
+ const sidecars = checkSidecars(stateRoot, readAgentSession, isPidRunning);
1746
+ checks.push(sidecars.check);
1747
+ const report = summarizeChecks(checks);
1748
+ console.log(formatReport(report, sidecars.sidecars));
1749
+ if (!report.ok) {
1750
+ process.exitCode = 1;
1751
+ }
1752
+ }
1753
+ /**
1754
+ * Block until the sidecar's inbox has something in it.
1755
+ *
1756
+ * This is the pull half of the wake story: `--on-message` pushes a turn at the
1757
+ * agent, `wait` lets a shell loop pull one. Exit code 0 means mail arrived,
1758
+ * 1 means the timeout elapsed, so `while ninja-p2p wait --id x; do ...; done`
1759
+ * behaves the way a shell author expects.
1760
+ */
1761
+ async function waitForInbox(stateDir, timeoutMs, intervalMs) {
1762
+ const startedAt = Date.now();
1763
+ for (;;) {
1764
+ const summary = getInboxSummary(stateDir);
1765
+ if (summary.pending > 0) {
1766
+ console.log(JSON.stringify({
1767
+ stateDir,
1768
+ pending: summary.pending,
1769
+ senders: summary.senders,
1770
+ types: summary.types,
1771
+ waitedMs: Date.now() - startedAt,
1772
+ }, null, 2));
1773
+ return;
1774
+ }
1775
+ if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
1776
+ console.log(JSON.stringify({
1777
+ stateDir,
1778
+ pending: 0,
1779
+ timedOut: true,
1780
+ waitedMs: Date.now() - startedAt,
1781
+ }, null, 2));
1782
+ process.exitCode = 1;
1783
+ return;
1784
+ }
1785
+ await delay(intervalMs);
1786
+ }
1787
+ }
1322
1788
  function isPidRunning(pid) {
1323
1789
  try {
1324
1790
  process.kill(pid, 0);
@@ -1328,9 +1794,34 @@ function isPidRunning(pid) {
1328
1794
  return false;
1329
1795
  }
1330
1796
  }
1797
+ /**
1798
+ * End the process explicitly once a command is done.
1799
+ *
1800
+ * `@roamhq/wrtc` segfaults during normal process teardown if a data channel
1801
+ * has ever existed, which corrupts the exit code (139/127) even on a fully
1802
+ * successful run. Any script doing `ninja-p2p wait && ...` would break on it.
1803
+ * Calling process.exit() skips the native teardown path and the crash with it,
1804
+ * so every command ends through here rather than by draining the event loop.
1805
+ *
1806
+ * This is safe because `VDOBridge.disconnect()` already waits for the SDK to
1807
+ * finish its own cleanup, so nothing meaningful is still in flight.
1808
+ */
1809
+ async function exitCleanly() {
1810
+ // Let buffered stdout reach the terminal or pipe before we cut the process off.
1811
+ await new Promise((resolve) => {
1812
+ if (process.stdout.write("")) {
1813
+ resolve();
1814
+ return;
1815
+ }
1816
+ process.stdout.once("drain", () => resolve());
1817
+ });
1818
+ process.exit(process.exitCode ?? 0);
1819
+ }
1331
1820
  const entry = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
1332
1821
  if (entry) {
1333
- main(process.argv.slice(2)).catch((error) => {
1822
+ main(process.argv.slice(2))
1823
+ .then(exitCleanly)
1824
+ .catch((error) => {
1334
1825
  console.error(error instanceof Error ? error.message : String(error));
1335
1826
  process.exit(1);
1336
1827
  });
package/dist/demo.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Live Self-Test
3
+ *
4
+ * Getting two agents talking used to take six commands and a room-name
5
+ * copy-paste, which is where most people gave up. `ninja-p2p demo` does the
6
+ * whole round trip in one command: two peers connect, find each other over
7
+ * WebRTC, exchange messages both ways, and complete a request/response.
8
+ *
9
+ * It doubles as a diagnostic. If this passes, the transport works on this
10
+ * machine and network, and any remaining problem is configuration.
11
+ */
12
+ export declare const DEMO_ALICE = "demo_alice";
13
+ export declare const DEMO_BOB = "demo_bob";
14
+ export type DemoStep = {
15
+ name: string;
16
+ ok: boolean;
17
+ detail: string;
18
+ ms: number;
19
+ };
20
+ export type DemoResult = {
21
+ room: string;
22
+ ok: boolean;
23
+ steps: DemoStep[];
24
+ };
25
+ export type DemoOptions = {
26
+ room?: string;
27
+ password?: string | false;
28
+ timeoutMs?: number;
29
+ log?: (message: string) => void;
30
+ /**
31
+ * Called once the round trip succeeds, while both peers are still connected.
32
+ * `--keep` uses it to hold the room open so it can be watched in the browser.
33
+ */
34
+ hold?: (room: string) => Promise<void>;
35
+ };
36
+ export declare function runDemo(options?: DemoOptions): Promise<DemoResult>;
37
+ export declare function formatDemoResult(result: DemoResult, dashboardUrl: string): string;