@vdoninja/ninja-p2p 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -825
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +17 -1
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +17 -1
  21. package/dist/message-bus.js +55 -16
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +280 -30
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/images/agent-room-dashboard.png +0 -0
  43. package/docs/protocol-and-reliability.md +231 -0
  44. package/docs/sdk-wishlist.md +236 -0
  45. package/docs/security.md +197 -0
  46. package/docs/social-stream-bridge.md +390 -0
  47. package/package.json +125 -113
package/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.1.2';
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) messages.shift();
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 (obj && obj.v === 1 && typeof obj.type === 'string' && obj.from) return obj;
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
- incomingTransfers.set(payload.transferId, {
615
- from: env.from,
616
- name: payload.name || 'download',
617
- mimeType: payload.mimeType || 'application/octet-stream',
618
- transferKind: payload.kind || 'file',
619
- size: payload.size || 0,
620
- sha256: payload.sha256 || '',
621
- totalChunks: payload.totalChunks || 0,
622
- receivedBytes: 0,
623
- parts: new Array(payload.totalChunks || 0),
624
- });
625
- addMessage('event', env.from.name || env.from.streamId, 'sending ' + (payload.name || 'file'), env.ts, payload.kind || 'file');
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
- const bytes = bytesFromBase64(payload.data || '');
634
- transfer.parts[payload.index] = bytes;
635
- transfer.receivedBytes += bytes.length;
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
- const missing = transfer.parts.some((part) => !part);
646
- if (missing) throw new Error('file transfer incomplete');
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 downloads',
749
- can: ['chat', 'direct-message', 'command', 'operator', 'browse-shares', 'download-file'],
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, { UUID: uuid }); } catch(err) {}
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, { UUID: uuid });
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 && orig.type) {
853
- addMessage(orig.type, (orig.from?.name || 'replay'),
854
- orig.payload?.text || orig.payload?.message || JSON.stringify(orig.payload),
855
- orig.ts,
856
- '[replay]');
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
- if (sdk) {
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
- if (type === 'command') {
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
- try {
1108
- if (opts && opts.to) {
1109
- // Find the peer's UUID for targeted send
1110
- const p = peers.get(opts.to);
1111
- if (p) {
1112
- sdk.sendData(env, { UUID: p.uuid });
1113
- } else {
1114
- sdk.sendData(env, { streamID: opts.to });
1115
- }
1116
- } else {
1117
- sdk.sendData(env);
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
- } catch(err) {
1120
- addMessage('system', 'System', 'Send error: ' + err.message, Date.now());
1440
+ await new Promise((resolve) => setTimeout(resolve, 50));
1121
1441
  }
1122
1442
  }
1123
1443
 
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { createMessageId, } from "./protocol.js";
4
4
  const SESSION_FILE = "session.json";
5
5
  const PEERS_FILE = "peers.json";
6
+ let lastQueuedActionAt = 0;
6
7
  export function ensureAgentState(stateDir) {
7
8
  const resolved = path.resolve(stateDir);
8
9
  const paths = {
@@ -40,7 +41,9 @@ export function queueAgentAction(stateDir, action) {
40
41
  const queued = {
41
42
  ...action,
42
43
  id: createMessageId(),
43
- createdAt: Date.now(),
44
+ // Date.now() can repeat during a burst. Keep timestamps monotonic so the
45
+ // filesystem outbox remains FIFO even when several actions share a tick.
46
+ createdAt: nextQueuedActionTimestamp(),
44
47
  };
45
48
  const filename = `${queued.createdAt}_${queued.id}.json`;
46
49
  writeJsonAtomic(path.join(paths.outboxDir, filename), queued);
@@ -163,6 +166,15 @@ export function isInboxWorthy(type) {
163
166
  "file_chunk",
164
167
  "file_complete",
165
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",
166
178
  ].includes(type);
167
179
  }
168
180
  function listJsonFiles(dir) {
@@ -173,6 +185,10 @@ function listJsonFiles(dir) {
173
185
  .map((name) => path.join(dir, name))
174
186
  .sort();
175
187
  }
188
+ function nextQueuedActionTimestamp() {
189
+ lastQueuedActionAt = Math.max(Date.now(), lastQueuedActionAt + 1);
190
+ return lastQueuedActionAt;
191
+ }
176
192
  function readJsonFile(filePath) {
177
193
  try {
178
194
  const raw = readFileSync(filePath, "utf8");