@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.
- package/.agents/skills/ninja-p2p/SKILL.md +102 -2
- package/.claude/skills/ninja-p2p/SKILL.md +209 -130
- package/.codex/skills/ninja-p2p/SKILL.md +102 -2
- package/CHANGELOG.md +113 -0
- package/README.md +1077 -825
- package/dashboard.html +370 -50
- package/dist/agent-state.js +17 -1
- package/dist/cli-lib.d.ts +40 -0
- package/dist/cli-lib.js +165 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +501 -10
- package/dist/demo.d.ts +37 -0
- package/dist/demo.js +186 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.js +219 -0
- package/dist/file-transfer.d.ts +15 -2
- package/dist/file-transfer.js +249 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/message-bus.d.ts +17 -1
- package/dist/message-bus.js +55 -16
- package/dist/peer-registry.js +7 -0
- package/dist/protocol.d.ts +78 -1
- package/dist/protocol.js +42 -6
- package/dist/shared-folders.js +20 -1
- package/dist/social-stream.d.ts +100 -0
- package/dist/social-stream.js +254 -0
- package/dist/swarm-manager.d.ts +164 -0
- package/dist/swarm-manager.js +957 -0
- package/dist/swarm-session.d.ts +197 -0
- package/dist/swarm-session.js +465 -0
- package/dist/swarm-wire.d.ts +48 -0
- package/dist/swarm-wire.js +86 -0
- package/dist/swarm.d.ts +258 -0
- package/dist/swarm.js +694 -0
- package/dist/vdo-bridge.d.ts +62 -1
- package/dist/vdo-bridge.js +280 -30
- package/dist/vdoninja-sdk-types.d.ts +75 -0
- package/dist/vdoninja-sdk-types.js +10 -0
- package/dist/wake.d.ts +83 -0
- package/dist/wake.js +206 -0
- package/docs/images/agent-room-dashboard.png +0 -0
- package/docs/protocol-and-reliability.md +231 -0
- package/docs/sdk-wishlist.md +236 -0
- package/docs/security.md +197 -0
- package/docs/social-stream-bridge.md +390 -0
- package/package.json +125 -113
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(
|
|
957
|
+
bridge.bus.send(envelope.from.streamId, "file_ack", createFailedFileAckPayload(transferId, error));
|
|
914
958
|
storeInboxMessage(stateDir, createFileTransferEventEnvelope(envelope.from, "file_receive_failed", {
|
|
915
|
-
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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))
|
|
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;
|