@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.
- 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 -775
- package/dashboard.html +370 -50
- package/dist/agent-state.js +9 -0
- 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 +15 -0
- package/dist/message-bus.js +32 -3
- 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 +273 -23
- 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/protocol-and-reliability.md +231 -38
- 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 -116
package/dashboard.html
CHANGED
|
@@ -97,15 +97,18 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
97
97
|
@media(max-width:960px){
|
|
98
98
|
body{flex-direction:column;height:auto;min-height:100vh;overflow:auto}
|
|
99
99
|
#sidebar{width:100%;height:auto;max-height:none;border-right:none;border-bottom:1px solid var(--border)}
|
|
100
|
+
#main{overflow:visible}
|
|
101
|
+
#messages{overflow:visible;padding-bottom:72px}
|
|
100
102
|
#peer-section{flex:none;max-height:180px}
|
|
101
103
|
#details-panel{max-height:220px}
|
|
102
|
-
#chat-bar{flex-wrap:wrap;align-items:stretch}
|
|
104
|
+
#chat-bar{position:fixed;z-index:10;left:0;right:0;bottom:0;flex-wrap:wrap;align-items:stretch;box-shadow:0 -4px 16px rgba(0,0,0,.35)}
|
|
103
105
|
#chat-bar select{flex:1 1 160px;min-width:0}
|
|
104
106
|
#chat-bar input{flex:1 1 100%}
|
|
105
107
|
#chat-bar button{flex:0 0 auto}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
@media(max-width:700px){
|
|
111
|
+
#messages{padding-bottom:124px}
|
|
109
112
|
#conn-panel .row,
|
|
110
113
|
.share-browser .row{flex-direction:column;align-items:stretch}
|
|
111
114
|
.details-actions button,
|
|
@@ -162,14 +165,22 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
162
165
|
let connected = false;
|
|
163
166
|
let myStreamId = '';
|
|
164
167
|
let myIdentity = null;
|
|
165
|
-
const DASHBOARD_VERSION = '0.
|
|
168
|
+
const DASHBOARD_VERSION = '0.2.0';
|
|
166
169
|
const peers = new Map(); // streamId -> {uuid, identity, skills, status, connected}
|
|
167
170
|
const peerUuidToStream = new Map();
|
|
168
171
|
const messages = [];
|
|
169
172
|
const MAX_MESSAGES = 500;
|
|
173
|
+
const MAX_BROWSER_RECEIVE_SIZE = 64 * 1024 * 1024;
|
|
174
|
+
const MAX_BROWSER_SEND_SIZE = 256 * 1024 * 1024;
|
|
175
|
+
const MAX_BROWSER_CHUNK_SIZE = 1024 * 1024;
|
|
176
|
+
const INCOMING_TRANSFER_TIMEOUT_MS = 120000;
|
|
177
|
+
const TRANSFER_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
|
178
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
179
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
170
180
|
let selectedPeer = ''; // empty = broadcast
|
|
171
181
|
const pendingRequests = new Map(); // requestId -> {command,target,args}
|
|
172
182
|
const incomingTransfers = new Map(); // transferId -> browser-side receive state
|
|
183
|
+
const pendingUploads = new Map(); // transferId -> {name,target,timer}
|
|
173
184
|
const shareBrowser = { peer: '', share: '', path: '', entries: [], truncated: false };
|
|
174
185
|
|
|
175
186
|
// ── DOM refs ───────────────────────────────────────────────────────────
|
|
@@ -231,6 +242,22 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
231
242
|
return bytes;
|
|
232
243
|
}
|
|
233
244
|
|
|
245
|
+
function bytesToBase64(bytes) {
|
|
246
|
+
// Chunked so a large file cannot blow the argument limit on apply().
|
|
247
|
+
let binary = '';
|
|
248
|
+
const step = 0x8000;
|
|
249
|
+
for (let i = 0; i < bytes.length; i += step) {
|
|
250
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + step));
|
|
251
|
+
}
|
|
252
|
+
return btoa(binary);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatBytes(size) {
|
|
256
|
+
if (size < 1024) return size + ' B';
|
|
257
|
+
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
|
|
258
|
+
return (size / (1024 * 1024)).toFixed(1) + ' MB';
|
|
259
|
+
}
|
|
260
|
+
|
|
234
261
|
function concatBytes(parts, totalSize) {
|
|
235
262
|
const output = new Uint8Array(totalSize);
|
|
236
263
|
let offset = 0;
|
|
@@ -300,7 +327,11 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
300
327
|
function addMessage(type, sender, body, ts, meta, extra) {
|
|
301
328
|
const msg = Object.assign({ type, sender, body, ts: ts || Date.now(), meta }, extra || {});
|
|
302
329
|
messages.push(msg);
|
|
303
|
-
if (messages.length > MAX_MESSAGES)
|
|
330
|
+
if (messages.length > MAX_MESSAGES) {
|
|
331
|
+
const removed = messages.shift();
|
|
332
|
+
if (removed && removed.downloadUrl) URL.revokeObjectURL(removed.downloadUrl);
|
|
333
|
+
messagesEl.firstElementChild?.remove();
|
|
334
|
+
}
|
|
304
335
|
renderMessage(msg);
|
|
305
336
|
}
|
|
306
337
|
|
|
@@ -435,6 +466,11 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
435
466
|
: '<div class="share-empty">' + escHtml(shareBrowser.share ? 'No listing loaded yet. Click Browse or List.' : 'Select a shared folder first.') + '</div>') +
|
|
436
467
|
'</div>' +
|
|
437
468
|
'</div></div>' : '',
|
|
469
|
+
'<div class="details-block"><span class="details-label">Send a File</span><div class="share-browser">' +
|
|
470
|
+
'<div class="row"><input type="file" id="upload-file"></div>' +
|
|
471
|
+
'<div class="share-browser-actions"><button type="button" id="btn-upload">Send to ' + escHtml(p.identity?.name || selectedPeer) + '</button></div>' +
|
|
472
|
+
'<div class="share-empty" id="upload-status"></div>' +
|
|
473
|
+
'</div></div>',
|
|
438
474
|
p.topics?.length ? '<div class="details-block"><span class="details-label">Topics</span><div class="details-value">' + escHtml(p.topics.join(', ')) + '</div></div>' : '',
|
|
439
475
|
p.version ? '<div class="details-block"><span class="details-label">Version</span><div class="details-value">' + escHtml(p.version) + '</div></div>' : ''
|
|
440
476
|
].join('');
|
|
@@ -445,6 +481,19 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
445
481
|
});
|
|
446
482
|
});
|
|
447
483
|
|
|
484
|
+
const uploadButton = detailsPanel.querySelector('#btn-upload');
|
|
485
|
+
if (uploadButton) {
|
|
486
|
+
uploadButton.addEventListener('click', () => {
|
|
487
|
+
const input = detailsPanel.querySelector('#upload-file');
|
|
488
|
+
const file = input && input.files && input.files[0];
|
|
489
|
+
if (!file) {
|
|
490
|
+
setUploadStatus('Choose a file first.');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
void sendFileToPeer(selectedPeer, file);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
448
497
|
detailsPanel.querySelectorAll('[data-share-open-root]').forEach((button) => {
|
|
449
498
|
button.addEventListener('click', () => {
|
|
450
499
|
const share = button.getAttribute('data-share-open-root') || '';
|
|
@@ -554,7 +603,18 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
554
603
|
function parseEnvelope(data) {
|
|
555
604
|
try {
|
|
556
605
|
const obj = typeof data === 'string' ? JSON.parse(data) : data;
|
|
557
|
-
if (
|
|
606
|
+
if (
|
|
607
|
+
obj &&
|
|
608
|
+
obj.v === 1 &&
|
|
609
|
+
typeof obj.id === 'string' &&
|
|
610
|
+
typeof obj.type === 'string' &&
|
|
611
|
+
obj.from &&
|
|
612
|
+
typeof obj.from === 'object' &&
|
|
613
|
+
typeof obj.from.streamId === 'string' &&
|
|
614
|
+
obj.from.streamId &&
|
|
615
|
+
Number.isFinite(obj.ts) &&
|
|
616
|
+
(obj.to === null || obj.to === undefined || typeof obj.to === 'string')
|
|
617
|
+
) return obj;
|
|
558
618
|
return null;
|
|
559
619
|
} catch(e) { return null; }
|
|
560
620
|
}
|
|
@@ -611,18 +671,37 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
611
671
|
|
|
612
672
|
function handleIncomingFileOffer(env) {
|
|
613
673
|
const payload = env.payload || {};
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
674
|
+
try {
|
|
675
|
+
validateIncomingFileOffer(payload);
|
|
676
|
+
if (incomingTransfers.has(payload.transferId)) {
|
|
677
|
+
throw new Error('transfer id is already active');
|
|
678
|
+
}
|
|
679
|
+
const promisedBytes = Array.from(incomingTransfers.values())
|
|
680
|
+
.reduce((total, transfer) => total + transfer.size, 0);
|
|
681
|
+
if (promisedBytes + payload.size > MAX_BROWSER_RECEIVE_SIZE) {
|
|
682
|
+
throw new Error('active browser downloads would exceed ' + formatBytes(MAX_BROWSER_RECEIVE_SIZE));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const transfer = {
|
|
686
|
+
from: env.from,
|
|
687
|
+
name: payload.name,
|
|
688
|
+
mimeType: payload.mimeType,
|
|
689
|
+
transferKind: payload.kind,
|
|
690
|
+
size: payload.size,
|
|
691
|
+
sha256: payload.sha256,
|
|
692
|
+
chunkSize: payload.chunkSize,
|
|
693
|
+
totalChunks: payload.totalChunks,
|
|
694
|
+
receivedChunks: 0,
|
|
695
|
+
receivedBytes: 0,
|
|
696
|
+
parts: new Array(payload.totalChunks),
|
|
697
|
+
timer: null,
|
|
698
|
+
};
|
|
699
|
+
incomingTransfers.set(payload.transferId, transfer);
|
|
700
|
+
armIncomingTransferTimeout(payload.transferId, transfer);
|
|
701
|
+
addMessage('event', env.from.name || env.from.streamId, 'sending ' + payload.name, env.ts, payload.kind);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
rejectIncomingTransfer(env, payload.transferId, err);
|
|
704
|
+
}
|
|
626
705
|
return true;
|
|
627
706
|
}
|
|
628
707
|
|
|
@@ -630,9 +709,31 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
630
709
|
const payload = env.payload || {};
|
|
631
710
|
const transfer = incomingTransfers.get(payload.transferId);
|
|
632
711
|
if (!transfer) return true;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
712
|
+
try {
|
|
713
|
+
if (transfer.from.streamId !== env.from.streamId) throw new Error('chunk came from the wrong peer');
|
|
714
|
+
if (!Number.isInteger(payload.index) || payload.index !== transfer.receivedChunks) {
|
|
715
|
+
throw new Error('unexpected chunk index');
|
|
716
|
+
}
|
|
717
|
+
if (payload.totalChunks !== transfer.totalChunks) throw new Error('chunk count changed');
|
|
718
|
+
if (typeof payload.data !== 'string' || !BASE64_PATTERN.test(payload.data)) {
|
|
719
|
+
throw new Error('chunk is not valid base64');
|
|
720
|
+
}
|
|
721
|
+
const expectedLength = Math.min(
|
|
722
|
+
transfer.chunkSize,
|
|
723
|
+
transfer.size - payload.index * transfer.chunkSize,
|
|
724
|
+
);
|
|
725
|
+
const maxEncodedLength = Math.ceil(expectedLength / 3) * 4;
|
|
726
|
+
if (payload.data.length !== maxEncodedLength) throw new Error('chunk length is invalid');
|
|
727
|
+
const bytes = bytesFromBase64(payload.data);
|
|
728
|
+
if (bytes.length !== expectedLength) throw new Error('chunk length is invalid');
|
|
729
|
+
|
|
730
|
+
transfer.parts[payload.index] = bytes;
|
|
731
|
+
transfer.receivedChunks += 1;
|
|
732
|
+
transfer.receivedBytes += bytes.length;
|
|
733
|
+
armIncomingTransferTimeout(payload.transferId, transfer);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
rejectIncomingTransfer(env, payload.transferId, err);
|
|
736
|
+
}
|
|
636
737
|
return true;
|
|
637
738
|
}
|
|
638
739
|
|
|
@@ -642,11 +743,16 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
642
743
|
if (!transfer) return true;
|
|
643
744
|
|
|
644
745
|
try {
|
|
645
|
-
|
|
646
|
-
if (
|
|
746
|
+
if (transfer.from.streamId !== env.from.streamId) throw new Error('completion came from the wrong peer');
|
|
747
|
+
if (payload.totalChunks !== transfer.totalChunks || transfer.receivedChunks !== transfer.totalChunks) {
|
|
748
|
+
throw new Error('file transfer incomplete');
|
|
749
|
+
}
|
|
647
750
|
if (transfer.receivedBytes !== transfer.size || payload.size !== transfer.size) {
|
|
648
751
|
throw new Error('file size mismatch');
|
|
649
752
|
}
|
|
753
|
+
if (payload.sha256 !== transfer.sha256 || !SHA256_PATTERN.test(payload.sha256 || '')) {
|
|
754
|
+
throw new Error('file checksum metadata changed');
|
|
755
|
+
}
|
|
650
756
|
|
|
651
757
|
const bytes = concatBytes(transfer.parts, transfer.size);
|
|
652
758
|
const sha = await sha256Hex(bytes);
|
|
@@ -685,6 +791,7 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
685
791
|
}, { to: env.from.streamId });
|
|
686
792
|
addMessage('system', 'System', 'File receive failed: ' + (err && err.message ? err.message : String(err)), Date.now());
|
|
687
793
|
} finally {
|
|
794
|
+
clearTimeout(transfer.timer);
|
|
688
795
|
incomingTransfers.delete(payload.transferId);
|
|
689
796
|
}
|
|
690
797
|
|
|
@@ -693,10 +800,71 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
693
800
|
|
|
694
801
|
function handleIncomingFileAck(env) {
|
|
695
802
|
const payload = env.payload || {};
|
|
803
|
+
const pending = pendingUploads.get(payload.transferId);
|
|
804
|
+
if (pending && pending.target === env.from.streamId) {
|
|
805
|
+
clearTimeout(pending.timer);
|
|
806
|
+
pendingUploads.delete(payload.transferId);
|
|
807
|
+
setUploadStatus(
|
|
808
|
+
payload.ok
|
|
809
|
+
? 'Delivered ' + pending.name + ' to ' + pending.target + '.'
|
|
810
|
+
: 'Delivery failed for ' + pending.name + ': ' + (payload.error || 'receiver rejected it'),
|
|
811
|
+
);
|
|
812
|
+
}
|
|
696
813
|
addMessage('event', env.from.name || env.from.streamId, (payload.ok ? 'delivery confirmed for ' : 'delivery failed for ') + (payload.name || payload.transferId), env.ts);
|
|
697
814
|
return true;
|
|
698
815
|
}
|
|
699
816
|
|
|
817
|
+
function validateIncomingFileOffer(payload) {
|
|
818
|
+
if (!TRANSFER_ID_PATTERN.test(payload.transferId || '')) throw new Error('invalid transfer id');
|
|
819
|
+
if (typeof payload.name !== 'string' || !payload.name.trim() || payload.name.length > 1024 || payload.name.includes('\0')) {
|
|
820
|
+
throw new Error('invalid file name');
|
|
821
|
+
}
|
|
822
|
+
if (typeof payload.mimeType !== 'string' || payload.mimeType.length > 256) throw new Error('invalid mime type');
|
|
823
|
+
if (payload.kind !== 'file' && payload.kind !== 'image') throw new Error('invalid transfer kind');
|
|
824
|
+
if (!Number.isSafeInteger(payload.size) || payload.size < 0 || payload.size > MAX_BROWSER_RECEIVE_SIZE) {
|
|
825
|
+
throw new Error('browser downloads are limited to ' + formatBytes(MAX_BROWSER_RECEIVE_SIZE));
|
|
826
|
+
}
|
|
827
|
+
if (!SHA256_PATTERN.test(payload.sha256 || '')) throw new Error('invalid file checksum');
|
|
828
|
+
if (!Number.isInteger(payload.chunkSize) || payload.chunkSize < 1024 || payload.chunkSize > MAX_BROWSER_CHUNK_SIZE) {
|
|
829
|
+
throw new Error('invalid chunk size');
|
|
830
|
+
}
|
|
831
|
+
const expectedChunks = payload.size === 0 ? 0 : Math.ceil(payload.size / payload.chunkSize);
|
|
832
|
+
if (!Number.isInteger(payload.totalChunks) || payload.totalChunks !== expectedChunks) {
|
|
833
|
+
throw new Error('chunk count does not match file size');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function armIncomingTransferTimeout(transferId, transfer) {
|
|
838
|
+
clearTimeout(transfer.timer);
|
|
839
|
+
transfer.timer = setTimeout(() => {
|
|
840
|
+
if (incomingTransfers.get(transferId) !== transfer) return;
|
|
841
|
+
incomingTransfers.delete(transferId);
|
|
842
|
+
sendEnvelope('file_ack', {
|
|
843
|
+
transferId: transferId,
|
|
844
|
+
ok: false,
|
|
845
|
+
error: 'file transfer timed out',
|
|
846
|
+
}, { to: transfer.from.streamId });
|
|
847
|
+
addMessage('system', 'System', 'File receive timed out: ' + transfer.name, Date.now());
|
|
848
|
+
}, INCOMING_TRANSFER_TIMEOUT_MS);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function rejectIncomingTransfer(env, transferId, error) {
|
|
852
|
+
const active = incomingTransfers.get(transferId);
|
|
853
|
+
if (active) {
|
|
854
|
+
clearTimeout(active.timer);
|
|
855
|
+
incomingTransfers.delete(transferId);
|
|
856
|
+
}
|
|
857
|
+
const message = error && error.message ? error.message : String(error);
|
|
858
|
+
if (TRANSFER_ID_PATTERN.test(transferId || '')) {
|
|
859
|
+
sendEnvelope('file_ack', {
|
|
860
|
+
transferId: transferId,
|
|
861
|
+
ok: false,
|
|
862
|
+
error: message,
|
|
863
|
+
}, { to: env.from.streamId });
|
|
864
|
+
}
|
|
865
|
+
addMessage('system', 'System', 'File receive rejected: ' + message, Date.now());
|
|
866
|
+
}
|
|
867
|
+
|
|
700
868
|
// ── Connection ─────────────────────────────────────────────────────────
|
|
701
869
|
async function doConnect() {
|
|
702
870
|
const room = inpRoom.value.trim();
|
|
@@ -726,6 +894,7 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
726
894
|
host: 'wss://wss.vdo.ninja',
|
|
727
895
|
debug: false
|
|
728
896
|
});
|
|
897
|
+
const connectionSdk = sdk;
|
|
729
898
|
|
|
730
899
|
// Wire events
|
|
731
900
|
sdk.addEventListener('peerConnected', (e) => {
|
|
@@ -745,8 +914,8 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
745
914
|
topics: ['events', 'bots', 'support'],
|
|
746
915
|
agent: {
|
|
747
916
|
runtime: 'dashboard-ui',
|
|
748
|
-
summary: 'Browser operator console for room chat, bot inspection, shared-folder browsing, and file
|
|
749
|
-
can: ['chat', 'direct-message', 'command', 'operator', 'browse-shares', '
|
|
917
|
+
summary: 'Browser operator console for room chat, bot inspection, shared-folder browsing, and checked file transfers',
|
|
918
|
+
can: ['chat', 'direct-message', 'command', 'operator', 'browse-shares', 'send-file', 'receive-file'],
|
|
750
919
|
asks: [
|
|
751
920
|
{ name: 'status', description: 'Ask connected bots for current status' },
|
|
752
921
|
{ name: 'peers', description: 'Ask connected bots for peer lists' },
|
|
@@ -755,7 +924,7 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
755
924
|
]
|
|
756
925
|
}
|
|
757
926
|
});
|
|
758
|
-
try { sdk.sendData(announce, {
|
|
927
|
+
try { sdk.sendData(announce, { uuid }); } catch(err) {}
|
|
759
928
|
});
|
|
760
929
|
|
|
761
930
|
sdk.addEventListener('dataReceived', (e) => {
|
|
@@ -766,6 +935,7 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
766
935
|
|
|
767
936
|
const sender = env.from.streamId;
|
|
768
937
|
if (!sender || sender === myStreamId) return;
|
|
938
|
+
if (env.to && env.to !== myStreamId) return;
|
|
769
939
|
// Update peer info
|
|
770
940
|
upsertPeer(sender, uuid, env.from);
|
|
771
941
|
|
|
@@ -843,18 +1013,22 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
843
1013
|
// Auto-respond with pong
|
|
844
1014
|
try {
|
|
845
1015
|
const pong = createEnvelope('pong', { pingId: env.id, pongTs: Date.now() }, { to: sender });
|
|
846
|
-
sdk.sendData(pong, {
|
|
1016
|
+
sdk.sendData(pong, { uuid });
|
|
847
1017
|
} catch(err) {}
|
|
848
1018
|
break;
|
|
849
1019
|
case 'history_replay': {
|
|
850
1020
|
// Replay contains an original message as payload
|
|
851
1021
|
const orig = env.payload;
|
|
852
|
-
if (orig
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1022
|
+
if (!orig || !orig.type || !orig.from || typeof orig.from.streamId !== 'string') break;
|
|
1023
|
+
// The replaying peer is allowed to share broadcasts and its
|
|
1024
|
+
// conversation with this dashboard, never somebody else's DMs.
|
|
1025
|
+
if (orig.from.streamId === myStreamId) break;
|
|
1026
|
+
if (orig.to && orig.to !== myStreamId) break;
|
|
1027
|
+
if (!['chat', 'event', 'command', 'command_response'].includes(orig.type)) break;
|
|
1028
|
+
addMessage(orig.type, (orig.from.name || orig.from.streamId || 'replay'),
|
|
1029
|
+
orig.payload?.text || orig.payload?.message || JSON.stringify(orig.payload),
|
|
1030
|
+
orig.ts,
|
|
1031
|
+
'[replay]');
|
|
858
1032
|
break;
|
|
859
1033
|
}
|
|
860
1034
|
default:
|
|
@@ -892,11 +1066,13 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
892
1066
|
});
|
|
893
1067
|
|
|
894
1068
|
sdk.addEventListener('disconnected', () => {
|
|
1069
|
+
if (sdk !== connectionSdk) return;
|
|
895
1070
|
statusEl.textContent = 'WebSocket lost, reconnecting...';
|
|
896
1071
|
dot.classList.remove('on');
|
|
897
1072
|
});
|
|
898
1073
|
|
|
899
1074
|
sdk.addEventListener('reconnected', () => {
|
|
1075
|
+
if (sdk !== connectionSdk) return;
|
|
900
1076
|
statusEl.textContent = 'Reconnected';
|
|
901
1077
|
dot.classList.add('on');
|
|
902
1078
|
});
|
|
@@ -934,12 +1110,17 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
934
1110
|
}
|
|
935
1111
|
|
|
936
1112
|
function doDisconnect() {
|
|
937
|
-
|
|
938
|
-
try { sdk.leaveRoom(); } catch(e) {}
|
|
939
|
-
try { sdk.disconnect(); } catch(e) {}
|
|
940
|
-
}
|
|
1113
|
+
const oldSdk = sdk;
|
|
941
1114
|
sdk = null;
|
|
942
1115
|
connected = false;
|
|
1116
|
+
if (oldSdk) {
|
|
1117
|
+
try { oldSdk.leaveRoom(); } catch(e) {}
|
|
1118
|
+
try { oldSdk.disconnect(); } catch(e) {}
|
|
1119
|
+
}
|
|
1120
|
+
for (const upload of pendingUploads.values()) clearTimeout(upload.timer);
|
|
1121
|
+
pendingUploads.clear();
|
|
1122
|
+
for (const transfer of incomingTransfers.values()) clearTimeout(transfer.timer);
|
|
1123
|
+
incomingTransfers.clear();
|
|
943
1124
|
peers.clear();
|
|
944
1125
|
dot.classList.remove('on');
|
|
945
1126
|
statusEl.textContent = 'Disconnected';
|
|
@@ -1093,10 +1274,128 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
1093
1274
|
addMessage('chat', myIdentity.name + (broadcastTarget ? ' -> ' + broadcastTarget : ''), text, Date.now());
|
|
1094
1275
|
}
|
|
1095
1276
|
|
|
1277
|
+
// Matches the CLI's default. The receiver rebuilds the file by appending
|
|
1278
|
+
// chunks in order, so these must be sent sequentially and not reordered.
|
|
1279
|
+
const UPLOAD_CHUNK_SIZE = 12000;
|
|
1280
|
+
const UPLOAD_BUFFER_HIGH_WATER = 512 * 1024;
|
|
1281
|
+
const UPLOAD_SEND_TIMEOUT_MS = 15000;
|
|
1282
|
+
const UPLOAD_ACK_TIMEOUT_MS = 30000;
|
|
1283
|
+
let uploadInProgress = false;
|
|
1284
|
+
|
|
1285
|
+
function setUploadStatus(text) {
|
|
1286
|
+
const el = document.getElementById('upload-status');
|
|
1287
|
+
if (el) el.textContent = text;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Send a local file to a peer using the same offer/chunk/complete protocol
|
|
1292
|
+
* the CLI speaks, so it lands in that peer's downloads folder.
|
|
1293
|
+
*/
|
|
1294
|
+
async function sendFileToPeer(target, file) {
|
|
1295
|
+
if (!sdk || !connected) {
|
|
1296
|
+
setUploadStatus('Not connected.');
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (uploadInProgress) {
|
|
1300
|
+
setUploadStatus('Another upload is already running.');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
uploadInProgress = true;
|
|
1305
|
+
let transferId = '';
|
|
1306
|
+
try {
|
|
1307
|
+
if (file.size > MAX_BROWSER_SEND_SIZE) {
|
|
1308
|
+
throw new Error(
|
|
1309
|
+
'browser uploads are limited to ' + formatBytes(MAX_BROWSER_SEND_SIZE) +
|
|
1310
|
+
'; use ninja-p2p seed/fetch for larger files',
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
setUploadStatus('Reading ' + file.name + '...');
|
|
1314
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
1315
|
+
const sha256 = await sha256Hex(bytes);
|
|
1316
|
+
transferId = randomId();
|
|
1317
|
+
const totalChunks = bytes.length === 0 ? 0 : Math.ceil(bytes.length / UPLOAD_CHUNK_SIZE);
|
|
1318
|
+
const kind = (file.type || '').startsWith('image/') ? 'image' : 'file';
|
|
1319
|
+
|
|
1320
|
+
await sendEnvelopeReliably('file_offer', {
|
|
1321
|
+
transferId: transferId,
|
|
1322
|
+
name: file.name,
|
|
1323
|
+
mimeType: file.type || 'application/octet-stream',
|
|
1324
|
+
kind: kind,
|
|
1325
|
+
size: bytes.length,
|
|
1326
|
+
sha256: sha256,
|
|
1327
|
+
chunkSize: UPLOAD_CHUNK_SIZE,
|
|
1328
|
+
totalChunks: totalChunks
|
|
1329
|
+
}, { to: target });
|
|
1330
|
+
|
|
1331
|
+
for (let index = 0; index < totalChunks; index += 1) {
|
|
1332
|
+
const start = index * UPLOAD_CHUNK_SIZE;
|
|
1333
|
+
const slice = bytes.subarray(start, Math.min(start + UPLOAD_CHUNK_SIZE, bytes.length));
|
|
1334
|
+
await sendEnvelopeReliably('file_chunk', {
|
|
1335
|
+
transferId: transferId,
|
|
1336
|
+
index: index,
|
|
1337
|
+
totalChunks: totalChunks,
|
|
1338
|
+
data: bytesToBase64(slice)
|
|
1339
|
+
}, { to: target });
|
|
1340
|
+
|
|
1341
|
+
// Older SDK builds do not expose bufferedAmount. Keep a small pacing
|
|
1342
|
+
// fallback there; current builds are governed by the actual queue.
|
|
1343
|
+
if (typeof sdk.getBufferedAmount !== 'function' && index % 8 === 7) {
|
|
1344
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1345
|
+
}
|
|
1346
|
+
if (index % 16 === 15) {
|
|
1347
|
+
setUploadStatus('Sending ' + file.name + '... ' + Math.floor(((index + 1) / totalChunks) * 100) + '%');
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const ackTimer = setTimeout(() => {
|
|
1352
|
+
if (!pendingUploads.delete(transferId)) return;
|
|
1353
|
+
setUploadStatus('Sent ' + file.name + ', but no confirmation arrived from ' + target + '.');
|
|
1354
|
+
}, UPLOAD_ACK_TIMEOUT_MS);
|
|
1355
|
+
pendingUploads.set(transferId, { name: file.name, target: target, timer: ackTimer });
|
|
1356
|
+
|
|
1357
|
+
await sendEnvelopeReliably('file_complete', {
|
|
1358
|
+
transferId: transferId,
|
|
1359
|
+
totalChunks: totalChunks,
|
|
1360
|
+
size: bytes.length,
|
|
1361
|
+
sha256: sha256
|
|
1362
|
+
}, { to: target });
|
|
1363
|
+
|
|
1364
|
+
setUploadStatus('Sent ' + file.name + ' (' + formatBytes(bytes.length) + '), waiting for confirmation...');
|
|
1365
|
+
addMessage('event', 'You', 'sent ' + file.name + ' to ' + target, Date.now(), kind);
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
const pending = transferId ? pendingUploads.get(transferId) : null;
|
|
1368
|
+
if (pending) {
|
|
1369
|
+
clearTimeout(pending.timer);
|
|
1370
|
+
pendingUploads.delete(transferId);
|
|
1371
|
+
}
|
|
1372
|
+
setUploadStatus('Upload failed: ' + err.message);
|
|
1373
|
+
} finally {
|
|
1374
|
+
uploadInProgress = false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function sendTarget(opts) {
|
|
1379
|
+
if (!opts || !opts.to) return undefined;
|
|
1380
|
+
const peer = peers.get(opts.to);
|
|
1381
|
+
return peer && peer.uuid ? { uuid: peer.uuid } : { streamID: opts.to };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function sendCreatedEnvelope(env, opts, reportError) {
|
|
1385
|
+
if (!sdk || !connected) return false;
|
|
1386
|
+
try {
|
|
1387
|
+
return sdk.sendData(env, sendTarget(opts)) !== false;
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
if (reportError) addMessage('system', 'System', 'Send error: ' + err.message, Date.now());
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1096
1394
|
function sendEnvelope(type, payload, opts) {
|
|
1097
|
-
if (!sdk || !connected) return;
|
|
1395
|
+
if (!sdk || !connected) return false;
|
|
1098
1396
|
const env = createEnvelope(type, payload, opts);
|
|
1099
|
-
|
|
1397
|
+
const sent = sendCreatedEnvelope(env, opts, true);
|
|
1398
|
+
if (sent && type === 'command') {
|
|
1100
1399
|
pendingRequests.set(env.id, {
|
|
1101
1400
|
command: payload && payload.command,
|
|
1102
1401
|
target: opts && opts.to ? opts.to : null,
|
|
@@ -1104,20 +1403,41 @@ input:focus,select:focus{border-color:var(--accent)}
|
|
|
1104
1403
|
createdAt: env.ts
|
|
1105
1404
|
});
|
|
1106
1405
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1406
|
+
if (!sent) {
|
|
1407
|
+
addMessage('system', 'System', 'Send rejected: no open channel to the requested peer.', Date.now());
|
|
1408
|
+
}
|
|
1409
|
+
return sent;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
async function waitForUploadCapacity(target, deadline) {
|
|
1413
|
+
if (!sdk || typeof sdk.getBufferedAmount !== 'function') return;
|
|
1414
|
+
const uuid = peers.get(target)?.uuid;
|
|
1415
|
+
if (!uuid) return;
|
|
1416
|
+
|
|
1417
|
+
while (sdk && connected) {
|
|
1418
|
+
const buffered = sdk.getBufferedAmount(uuid);
|
|
1419
|
+
if (buffered === null || buffered < UPLOAD_BUFFER_HIGH_WATER) return;
|
|
1420
|
+
if (Date.now() >= deadline) throw new Error('data channel did not drain');
|
|
1421
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1422
|
+
}
|
|
1423
|
+
throw new Error('disconnected during upload');
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* A false send means the SDK did not accept the message. Retry the same
|
|
1428
|
+
* envelope (and therefore the same deduplication id) after the channel drains.
|
|
1429
|
+
*/
|
|
1430
|
+
async function sendEnvelopeReliably(type, payload, opts) {
|
|
1431
|
+
const deadline = Date.now() + UPLOAD_SEND_TIMEOUT_MS;
|
|
1432
|
+
const env = createEnvelope(type, payload, opts);
|
|
1433
|
+
for (;;) {
|
|
1434
|
+
if (!sdk || !connected) throw new Error('disconnected during upload');
|
|
1435
|
+
await waitForUploadCapacity(opts && opts.to, deadline);
|
|
1436
|
+
if (sendCreatedEnvelope(env, opts, false)) return;
|
|
1437
|
+
if (Date.now() >= deadline) {
|
|
1438
|
+
throw new Error('peer did not accept ' + type + ' within ' + (UPLOAD_SEND_TIMEOUT_MS / 1000) + 's');
|
|
1118
1439
|
}
|
|
1119
|
-
|
|
1120
|
-
addMessage('system', 'System', 'Send error: ' + err.message, Date.now());
|
|
1440
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1121
1441
|
}
|
|
1122
1442
|
}
|
|
1123
1443
|
|
package/dist/agent-state.js
CHANGED
|
@@ -166,6 +166,15 @@ export function isInboxWorthy(type) {
|
|
|
166
166
|
"file_chunk",
|
|
167
167
|
"file_complete",
|
|
168
168
|
"file_ack",
|
|
169
|
+
// Swarm traffic is machine-to-machine and high volume. A single file would
|
|
170
|
+
// otherwise write thousands of inbox entries and bury every real message.
|
|
171
|
+
"swarm_offer",
|
|
172
|
+
"swarm_manifest_request",
|
|
173
|
+
"swarm_manifest_page",
|
|
174
|
+
"swarm_announce",
|
|
175
|
+
"swarm_request",
|
|
176
|
+
"swarm_chunk",
|
|
177
|
+
"swarm_have",
|
|
169
178
|
].includes(type);
|
|
170
179
|
}
|
|
171
180
|
function listJsonFiles(dir) {
|
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;
|