@vdoninja/ninja-p2p 0.1.1 → 0.1.2

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/dashboard.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>NinjaClawd P2P Dashboard</title>
6
+ <title>Ninja P2P Dashboard</title>
7
7
  <style>
8
8
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
9
  :root{
@@ -22,11 +22,11 @@ input,select{font-family:var(--font);font-size:12px;background:var(--bg);color:v
22
22
  input:focus,select:focus{border-color:var(--accent)}
23
23
 
24
24
  /* Layout */
25
- #sidebar{width:240px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
25
+ #sidebar{width:240px;min-width:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
26
26
  #sidebar h1{font-size:14px;padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
27
27
  #sidebar h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--red);flex-shrink:0}
28
28
  #sidebar h1 .dot.on{background:var(--green)}
29
- #main{flex:1;display:flex;flex-direction:column;overflow:hidden}
29
+ #main{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden}
30
30
 
31
31
  /* Connection Panel */
32
32
  #conn-panel{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--bg2)}
@@ -34,7 +34,7 @@ input:focus,select:focus{border-color:var(--accent)}
34
34
  #conn-panel label:first-child{margin-top:0}
35
35
  #conn-panel input{width:100%;margin-bottom:2px}
36
36
  #conn-panel .row{display:flex;gap:6px;margin-top:10px}
37
- #conn-panel .row button{flex:1}
37
+ #conn-panel .row button{flex:1;min-width:0}
38
38
  #conn-panel .status{font-size:11px;color:var(--fg2);margin-top:6px;text-align:center}
39
39
 
40
40
  /* Peer List */
@@ -51,7 +51,7 @@ input:focus,select:focus{border-color:var(--accent)}
51
51
  .peer-item .role{font-size:10px;color:var(--fg2)}
52
52
 
53
53
  /* Main content */
54
- #messages{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px}
54
+ #messages{flex:1;min-width:0;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px}
55
55
  .msg{padding:2px 0;word-break:break-word}
56
56
  .msg .ts{color:var(--fg2);font-size:11px;margin-right:6px}
57
57
  .msg .sender{font-weight:600;margin-right:4px}
@@ -62,29 +62,62 @@ input:focus,select:focus{border-color:var(--accent)}
62
62
  .msg.type-command_response .sender{color:var(--yellow)}
63
63
  .msg.type-system{color:var(--fg2);font-style:italic}
64
64
  .msg.type-system .sender{color:var(--fg2)}
65
- .msg .body{color:var(--fg)}
66
- .msg .meta{color:var(--fg2);font-size:11px}
65
+ .msg .body{color:var(--fg);overflow-wrap:anywhere}
66
+ .msg .meta{color:var(--fg2);font-size:11px;overflow-wrap:anywhere}
67
67
 
68
68
  /* Chat Input */
69
69
  #chat-bar{padding:8px 16px;border-top:1px solid var(--border);background:var(--bg2);display:flex;gap:6px;align-items:center}
70
- #chat-bar input{flex:1}
71
- #chat-bar select{width:auto;min-width:100px}
70
+ #chat-bar input{flex:1;min-width:0}
71
+ #chat-bar select{width:auto;min-width:100px;max-width:100%}
72
72
 
73
- /* Skills panel */
74
- #skills-panel{padding:8px 16px;border-top:1px solid var(--border);font-size:11px;color:var(--fg2);max-height:80px;overflow-y:auto}
73
+ /* Peer details panel */
74
+ #details-panel{padding:10px 16px;border-top:1px solid var(--border);font-size:11px;color:var(--fg2);max-height:280px;min-width:0;overflow-y:auto;overflow-x:hidden}
75
+ .details-title{font-size:11px;color:var(--fg2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
76
+ .details-block{margin-bottom:8px}
77
+ .details-label{color:var(--fg2);display:block;margin-bottom:2px}
78
+ .details-value{color:var(--fg);word-break:break-word;overflow-wrap:anywhere}
75
79
  .skill-tag{display:inline-block;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:1px 2px;font-size:10px}
80
+ .share-tag{display:inline-flex;max-width:100%;align-items:center;gap:4px;flex-wrap:wrap;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:1px 2px;font-size:10px}
81
+ .share-tag button{padding:1px 6px;font-size:10px}
82
+ .ask-item{margin:3px 0;padding-left:10px;position:relative}
83
+ .ask-item::before{content:'>';position:absolute;left:0;color:var(--fg2)}
84
+ .details-actions,.share-browser-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
85
+ .details-actions button,.share-browser-actions button{padding:3px 8px;flex:1 1 calc(50% - 3px);min-width:0}
86
+ .share-browser{border:1px solid var(--border);border-radius:6px;padding:8px;background:var(--bg)}
87
+ .share-browser .row{display:flex;gap:6px;align-items:center;margin-top:6px}
88
+ .share-browser .row:first-child{margin-top:0}
89
+ .share-browser .row input,.share-browser .row select{flex:1;min-width:0}
90
+ .share-list{margin-top:8px;border-top:1px solid var(--border);padding-top:6px}
91
+ .share-entry{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:4px 0}
92
+ .share-entry .path{flex:1;color:var(--fg);word-break:break-word}
93
+ .share-entry .meta{color:var(--fg2);font-size:10px;white-space:nowrap}
94
+ .share-empty{color:var(--fg2)}
76
95
 
77
96
  /* Responsive */
78
- @media(max-width:600px){
79
- #sidebar{width:100%;height:auto;max-height:40vh;border-right:none;border-bottom:1px solid var(--border)}
80
- body{flex-direction:column}
97
+ @media(max-width:960px){
98
+ body{flex-direction:column;height:auto;min-height:100vh;overflow:auto}
99
+ #sidebar{width:100%;height:auto;max-height:none;border-right:none;border-bottom:1px solid var(--border)}
100
+ #peer-section{flex:none;max-height:180px}
101
+ #details-panel{max-height:220px}
102
+ #chat-bar{flex-wrap:wrap;align-items:stretch}
103
+ #chat-bar select{flex:1 1 160px;min-width:0}
104
+ #chat-bar input{flex:1 1 100%}
105
+ #chat-bar button{flex:0 0 auto}
106
+ }
107
+
108
+ @media(max-width:700px){
109
+ #conn-panel .row,
110
+ .share-browser .row{flex-direction:column;align-items:stretch}
111
+ .details-actions button,
112
+ .share-browser-actions button,
113
+ #chat-bar button{flex:1 1 100%}
81
114
  }
82
115
  </style>
83
116
  </head>
84
117
  <body>
85
118
 
86
119
  <div id="sidebar">
87
- <h1><span class="dot" id="conn-dot"></span> NinjaClawd</h1>
120
+ <h1><span class="dot" id="conn-dot"></span> Ninja P2P</h1>
88
121
 
89
122
  <div id="conn-panel">
90
123
  <label>Room</label>
@@ -105,7 +138,7 @@ input:focus,select:focus{border-color:var(--accent)}
105
138
  <div id="peer-list"></div>
106
139
  </div>
107
140
 
108
- <div id="skills-panel"></div>
141
+ <div id="details-panel"></div>
109
142
  </div>
110
143
 
111
144
  <div id="main">
@@ -129,10 +162,15 @@ input:focus,select:focus{border-color:var(--accent)}
129
162
  let connected = false;
130
163
  let myStreamId = '';
131
164
  let myIdentity = null;
165
+ const DASHBOARD_VERSION = '0.1.2';
132
166
  const peers = new Map(); // streamId -> {uuid, identity, skills, status, connected}
167
+ const peerUuidToStream = new Map();
133
168
  const messages = [];
134
169
  const MAX_MESSAGES = 500;
135
170
  let selectedPeer = ''; // empty = broadcast
171
+ const pendingRequests = new Map(); // requestId -> {command,target,args}
172
+ const incomingTransfers = new Map(); // transferId -> browser-side receive state
173
+ const shareBrowser = { peer: '', share: '', path: '', entries: [], truncated: false };
136
174
 
137
175
  // ── DOM refs ───────────────────────────────────────────────────────────
138
176
  const $ = (id) => document.getElementById(id);
@@ -143,7 +181,7 @@ input:focus,select:focus{border-color:var(--accent)}
143
181
  const messagesEl = $('messages');
144
182
  const chatInput = $('chat-input');
145
183
  const chatTarget = $('chat-target');
146
- const skillsPanel = $('skills-panel');
184
+ const detailsPanel = $('details-panel');
147
185
  const btnConnect = $('btn-connect');
148
186
  const btnDisconnect = $('btn-disconnect');
149
187
  const btnSend = $('btn-send');
@@ -158,9 +196,9 @@ input:focus,select:focus{border-color:var(--accent)}
158
196
  if (params.get('name')) inpName.value = params.get('name');
159
197
 
160
198
  // Restore from localStorage
161
- if (!inpRoom.value) inpRoom.value = localStorage.getItem('clawd_room') || '';
162
- if (!inpPassword.value) inpPassword.value = localStorage.getItem('clawd_password') || 'false';
163
- if (!inpName.value || inpName.value === 'Dashboard') inpName.value = localStorage.getItem('clawd_name') || 'Dashboard';
199
+ if (!inpRoom.value) inpRoom.value = localStorage.getItem('ninja_room') || '';
200
+ if (!inpPassword.value) inpPassword.value = localStorage.getItem('ninja_password') || 'false';
201
+ if (!inpName.value || inpName.value === 'Dashboard') inpName.value = localStorage.getItem('ninja_name') || 'Dashboard';
164
202
 
165
203
  // ── Helpers ────────────────────────────────────────────────────────────
166
204
  function randomId() {
@@ -184,9 +222,83 @@ input:focus,select:focus{border-color:var(--accent)}
184
222
  return d.innerHTML;
185
223
  }
186
224
 
225
+ function bytesFromBase64(value) {
226
+ const binary = atob(value);
227
+ const bytes = new Uint8Array(binary.length);
228
+ for (let i = 0; i < binary.length; i += 1) {
229
+ bytes[i] = binary.charCodeAt(i);
230
+ }
231
+ return bytes;
232
+ }
233
+
234
+ function concatBytes(parts, totalSize) {
235
+ const output = new Uint8Array(totalSize);
236
+ let offset = 0;
237
+ for (const part of parts) {
238
+ output.set(part, offset);
239
+ offset += part.length;
240
+ }
241
+ return output;
242
+ }
243
+
244
+ async function sha256Hex(bytes) {
245
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
246
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
247
+ }
248
+
249
+ function fmtBytes(size) {
250
+ if (typeof size !== 'number' || !Number.isFinite(size)) return '';
251
+ if (size < 1024) return size + ' B';
252
+ if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
253
+ return (size / (1024 * 1024)).toFixed(1) + ' MB';
254
+ }
255
+
256
+ function getSelectedPeerRecord() {
257
+ return selectedPeer ? peers.get(selectedPeer) : null;
258
+ }
259
+
260
+ function getPeerKeyByUuid(uuid) {
261
+ if (!uuid) return '';
262
+ return peerUuidToStream.get(uuid) || '';
263
+ }
264
+
265
+ function upsertPeer(streamId, uuid, identity) {
266
+ const key = streamId || uuid;
267
+ if (!key) return null;
268
+
269
+ const knownKey = uuid ? getPeerKeyByUuid(uuid) : '';
270
+ if (uuid && streamId && knownKey && knownKey !== streamId && peers.has(knownKey)) {
271
+ const existing = peers.get(knownKey);
272
+ peers.delete(knownKey);
273
+ peers.set(streamId, Object.assign(existing || {}, { uuid, identity: identity || existing?.identity || null }));
274
+ peerUuidToStream.set(uuid, streamId);
275
+ return peers.get(streamId);
276
+ }
277
+
278
+ const provisional = uuid && uuid !== key && peers.has(uuid) ? peers.get(uuid) : null;
279
+ if (provisional && !peers.has(key)) {
280
+ peers.delete(uuid);
281
+ peers.set(key, Object.assign(provisional, { uuid, identity: identity || provisional.identity || null }));
282
+ peerUuidToStream.set(uuid, key);
283
+ return peers.get(key);
284
+ }
285
+
286
+ if (!peers.has(key)) {
287
+ peers.set(key, { uuid, identity: identity || null, skills: [], status: 'online', connected: true });
288
+ } else {
289
+ const peer = peers.get(key);
290
+ peer.uuid = uuid || peer.uuid || '';
291
+ peer.identity = identity || peer.identity || null;
292
+ peer.connected = true;
293
+ }
294
+
295
+ if (uuid) peerUuidToStream.set(uuid, key);
296
+ return peers.get(key);
297
+ }
298
+
187
299
  // ── Message Display ────────────────────────────────────────────────────
188
- function addMessage(type, sender, body, ts, meta) {
189
- const msg = { type, sender, body, ts: ts || Date.now(), meta };
300
+ function addMessage(type, sender, body, ts, meta, extra) {
301
+ const msg = Object.assign({ type, sender, body, ts: ts || Date.now(), meta }, extra || {});
190
302
  messages.push(msg);
191
303
  if (messages.length > MAX_MESSAGES) messages.shift();
192
304
  renderMessage(msg);
@@ -200,6 +312,14 @@ input:focus,select:focus{border-color:var(--accent)}
200
312
  html += '<span class="body">' + escHtml(String(msg.body)) + '</span>';
201
313
  if (msg.meta) html += ' <span class="meta">' + escHtml(msg.meta) + '</span>';
202
314
  div.innerHTML = html;
315
+ if (msg.downloadUrl) {
316
+ const link = document.createElement('a');
317
+ link.href = msg.downloadUrl;
318
+ link.download = msg.downloadName || 'download';
319
+ link.textContent = ' download';
320
+ link.className = 'meta';
321
+ div.appendChild(link);
322
+ }
203
323
  messagesEl.appendChild(div);
204
324
  messagesEl.scrollTop = messagesEl.scrollHeight;
205
325
  }
@@ -228,7 +348,7 @@ input:focus,select:focus{border-color:var(--accent)}
228
348
  selectedPeer = selectedPeer === sid ? '' : sid;
229
349
  chatTarget.value = selectedPeer;
230
350
  renderPeers();
231
- renderSkills();
351
+ renderDetails();
232
352
  });
233
353
  peerListEl.appendChild(div);
234
354
 
@@ -241,18 +361,180 @@ input:focus,select:focus{border-color:var(--accent)}
241
361
  chatTarget.value = curVal || '';
242
362
  }
243
363
 
244
- function renderSkills() {
364
+ function renderDetails() {
245
365
  if (!selectedPeer) {
246
- skillsPanel.innerHTML = '';
366
+ detailsPanel.innerHTML = '<div class="details-title">Peer Details</div><div class="details-value">Select a bot to see its profile, capabilities, shares, and quick actions.</div>';
247
367
  return;
248
368
  }
249
369
  const p = peers.get(selectedPeer);
250
- if (!p || !p.skills || p.skills.length === 0) {
251
- skillsPanel.innerHTML = '<em>No skills announced</em>';
370
+ if (!p) {
371
+ detailsPanel.innerHTML = '<div class="details-title">Peer Details</div><div class="details-value">Peer not found.</div>';
372
+ return;
373
+ }
374
+ const agent = p.agentProfile || {};
375
+ const skills = (p.skills || []).map((s) => '<span class="skill-tag">' + escHtml(s) + '</span>').join(' ');
376
+ const can = (agent.can || []).map((s) => '<span class="skill-tag">' + escHtml(s) + '</span>').join(' ');
377
+ const asks = (agent.asks || []).map((ask) =>
378
+ '<div class="ask-item"><strong>' + escHtml(ask.name) + '</strong>: ' + escHtml(ask.description || '') + '</div>'
379
+ ).join('');
380
+ const shares = Array.isArray(agent.shares) ? agent.shares : [];
381
+
382
+ if (shareBrowser.peer !== selectedPeer) {
383
+ shareBrowser.peer = selectedPeer;
384
+ shareBrowser.share = shares[0] && shares[0].name ? shares[0].name : '';
385
+ shareBrowser.path = '';
386
+ shareBrowser.entries = [];
387
+ shareBrowser.truncated = false;
388
+ }
389
+
390
+ const shareOptions = shares.map((share) =>
391
+ '<option value="' + escHtml(share.name) + '"' + (shareBrowser.share === share.name ? ' selected' : '') + '>' + escHtml(share.name) + '</option>'
392
+ ).join('');
393
+ const shareTags = shares.map((share) =>
394
+ '<span class="share-tag">' +
395
+ escHtml(share.name) +
396
+ '<button type="button" data-share-open-root="' + escHtml(share.name) + '">Browse</button>' +
397
+ '</span>'
398
+ ).join(' ');
399
+ const shareEntries = (shareBrowser.entries || []).map((entry) =>
400
+ '<div class="share-entry">' +
401
+ '<div class="path">' + escHtml(entry.path) + '</div>' +
402
+ '<div class="meta">' + escHtml(entry.type === 'dir' ? 'folder' : fmtBytes(entry.size)) + '</div>' +
403
+ (entry.type === 'dir'
404
+ ? '<button type="button" data-share-open="' + escHtml(entry.path) + '">Open</button>'
405
+ : '<button type="button" data-share-get="' + escHtml(entry.path) + '">Get</button>') +
406
+ '</div>'
407
+ ).join('');
408
+
409
+ detailsPanel.innerHTML = [
410
+ '<div class="details-title">Peer Details</div>',
411
+ '<div class="details-block"><span class="details-label">Name</span><div class="details-value">' + escHtml(p.identity?.name || selectedPeer) + '</div></div>',
412
+ '<div class="details-block"><span class="details-label">Role</span><div class="details-value">' + escHtml(p.identity?.role || 'unknown') + '</div></div>',
413
+ '<div class="details-block"><span class="details-label">Status</span><div class="details-value">' + escHtml(p.status || 'online') + (p.statusDetail ? ' - ' + escHtml(p.statusDetail) : '') + '</div></div>',
414
+ '<div class="details-actions">' +
415
+ '<button type="button" data-peer-command="status">Status</button>' +
416
+ '<button type="button" data-peer-command="profile">Profile</button>' +
417
+ '<button type="button" data-peer-command="capabilities">Capabilities</button>' +
418
+ '<button type="button" data-peer-command="shares">Shares</button>' +
419
+ '</div>',
420
+ agent.summary ? '<div class="details-block"><span class="details-label">Summary</span><div class="details-value">' + escHtml(agent.summary) + '</div></div>' : '',
421
+ (agent.runtime || agent.provider || agent.model)
422
+ ? '<div class="details-block"><span class="details-label">Runtime</span><div class="details-value">' + escHtml([agent.runtime, agent.provider, agent.model].filter(Boolean).join(' / ')) + '</div></div>'
423
+ : '',
424
+ agent.workspace ? '<div class="details-block"><span class="details-label">Workspace</span><div class="details-value">' + escHtml(agent.workspace) + '</div></div>' : '',
425
+ skills ? '<div class="details-block"><span class="details-label">Skills</span><div class="details-value">' + skills + '</div></div>' : '',
426
+ can ? '<div class="details-block"><span class="details-label">Can</span><div class="details-value">' + can + '</div></div>' : '',
427
+ asks ? '<div class="details-block"><span class="details-label">Asks</span><div class="details-value">' + asks + '</div></div>' : '',
428
+ shareTags ? '<div class="details-block"><span class="details-label">Shared Folders</span><div class="details-value">' + shareTags + '</div></div>' : '',
429
+ shares.length ? '<div class="details-block"><span class="details-label">Browse Shared Files</span><div class="share-browser">' +
430
+ '<div class="row"><select id="share-select">' + shareOptions + '</select><input id="share-path" placeholder="folder or file path" value="' + escHtml(shareBrowser.path || '') + '" autocomplete="off"></div>' +
431
+ '<div class="share-browser-actions"><button type="button" id="btn-share-list">List</button><button type="button" id="btn-share-get">Get File</button></div>' +
432
+ '<div class="share-list">' +
433
+ (shareBrowser.entries.length
434
+ ? shareEntries + (shareBrowser.truncated ? '<div class="share-empty">Showing the first entries only.</div>' : '')
435
+ : '<div class="share-empty">' + escHtml(shareBrowser.share ? 'No listing loaded yet. Click Browse or List.' : 'Select a shared folder first.') + '</div>') +
436
+ '</div>' +
437
+ '</div></div>' : '',
438
+ p.topics?.length ? '<div class="details-block"><span class="details-label">Topics</span><div class="details-value">' + escHtml(p.topics.join(', ')) + '</div></div>' : '',
439
+ p.version ? '<div class="details-block"><span class="details-label">Version</span><div class="details-value">' + escHtml(p.version) + '</div></div>' : ''
440
+ ].join('');
441
+
442
+ detailsPanel.querySelectorAll('[data-peer-command]').forEach((button) => {
443
+ button.addEventListener('click', () => {
444
+ sendPeerCommand(selectedPeer, button.getAttribute('data-peer-command'));
445
+ });
446
+ });
447
+
448
+ detailsPanel.querySelectorAll('[data-share-open-root]').forEach((button) => {
449
+ button.addEventListener('click', () => {
450
+ const share = button.getAttribute('data-share-open-root') || '';
451
+ shareBrowser.peer = selectedPeer;
452
+ shareBrowser.share = share;
453
+ shareBrowser.path = '';
454
+ requestShareListing(selectedPeer, share, '');
455
+ });
456
+ });
457
+
458
+ const shareSelect = $('share-select');
459
+ const sharePathInput = $('share-path');
460
+ if (shareSelect) {
461
+ shareSelect.addEventListener('change', () => {
462
+ shareBrowser.peer = selectedPeer;
463
+ shareBrowser.share = shareSelect.value;
464
+ });
465
+ }
466
+ if (sharePathInput) {
467
+ sharePathInput.addEventListener('input', () => {
468
+ shareBrowser.path = sharePathInput.value;
469
+ });
470
+ }
471
+
472
+ const btnShareList = $('btn-share-list');
473
+ if (btnShareList) {
474
+ btnShareList.addEventListener('click', () => {
475
+ const share = shareSelect ? shareSelect.value : shareBrowser.share;
476
+ const pathValue = sharePathInput ? sharePathInput.value.trim() : shareBrowser.path;
477
+ requestShareListing(selectedPeer, share, pathValue);
478
+ });
479
+ }
480
+
481
+ const btnShareGet = $('btn-share-get');
482
+ if (btnShareGet) {
483
+ btnShareGet.addEventListener('click', () => {
484
+ const share = shareSelect ? shareSelect.value : shareBrowser.share;
485
+ const pathValue = sharePathInput ? sharePathInput.value.trim() : shareBrowser.path;
486
+ requestSharedFile(selectedPeer, share, pathValue);
487
+ });
488
+ }
489
+
490
+ detailsPanel.querySelectorAll('[data-share-open]').forEach((button) => {
491
+ button.addEventListener('click', () => {
492
+ const entryPath = button.getAttribute('data-share-open') || '';
493
+ requestShareListing(selectedPeer, shareBrowser.share, entryPath);
494
+ });
495
+ });
496
+
497
+ detailsPanel.querySelectorAll('[data-share-get]').forEach((button) => {
498
+ button.addEventListener('click', () => {
499
+ const entryPath = button.getAttribute('data-share-get') || '';
500
+ requestSharedFile(selectedPeer, shareBrowser.share, entryPath);
501
+ });
502
+ });
503
+ }
504
+
505
+ function sendPeerCommand(target, command, args) {
506
+ if (!connected) {
507
+ addMessage('system', 'System', 'Not connected', Date.now());
508
+ return;
509
+ }
510
+ if (!target) {
511
+ addMessage('system', 'System', 'Select a peer first', Date.now());
512
+ return;
513
+ }
514
+ sendEnvelope('command', { command, args }, { to: target });
515
+ addMessage('command', myIdentity.name + ' -> ' + target, 'CMD: ' + command + (args !== undefined ? ' ' + JSON.stringify(args) : ''), Date.now());
516
+ }
517
+
518
+ function requestShareListing(target, share, folderPath) {
519
+ if (!share) {
520
+ addMessage('system', 'System', 'Pick a shared folder first', Date.now());
521
+ return;
522
+ }
523
+ shareBrowser.peer = target;
524
+ shareBrowser.share = share;
525
+ shareBrowser.path = folderPath || '';
526
+ shareBrowser.entries = [];
527
+ shareBrowser.truncated = false;
528
+ sendPeerCommand(target, 'list-files', { share, path: folderPath || '' });
529
+ renderDetails();
530
+ }
531
+
532
+ function requestSharedFile(target, share, filePath) {
533
+ if (!share || !filePath) {
534
+ addMessage('system', 'System', 'Choose a share and a file path first', Date.now());
252
535
  return;
253
536
  }
254
- skillsPanel.innerHTML = '<strong>Skills:</strong> ' +
255
- p.skills.map(s => '<span class="skill-tag">' + escHtml(s) + '</span>').join('');
537
+ sendPeerCommand(target, 'get-file', { share, path: filePath });
256
538
  }
257
539
 
258
540
  // ── Envelope Protocol ──────────────────────────────────────────────────
@@ -277,6 +559,144 @@ input:focus,select:focus{border-color:var(--accent)}
277
559
  } catch(e) { return null; }
278
560
  }
279
561
 
562
+ function handleCommandResponse(env) {
563
+ const payload = env.payload || {};
564
+ const requestId = payload.requestId;
565
+ const pending = requestId ? pendingRequests.get(requestId) : null;
566
+ const result = payload.result || null;
567
+
568
+ if (pending && pending.command === 'shares' && result && Array.isArray(result.shares)) {
569
+ const peer = peers.get(env.from.streamId);
570
+ if (peer) {
571
+ peer.agentProfile = peer.agentProfile || {};
572
+ peer.agentProfile.shares = result.shares;
573
+ }
574
+ if (selectedPeer === env.from.streamId) {
575
+ shareBrowser.peer = env.from.streamId;
576
+ shareBrowser.share = result.shares[0] && result.shares[0].name ? result.shares[0].name : '';
577
+ shareBrowser.path = '';
578
+ shareBrowser.entries = [];
579
+ shareBrowser.truncated = false;
580
+ renderDetails();
581
+ }
582
+ addMessage('command_response', env.from.name || env.from.streamId, 'shares: ' + (result.shares.map((share) => share.name).join(', ') || 'none'), env.ts);
583
+ return true;
584
+ }
585
+
586
+ if (pending && pending.command === 'list-files' && result && Array.isArray(result.entries)) {
587
+ shareBrowser.peer = env.from.streamId;
588
+ shareBrowser.share = result.share || (pending.args && pending.args.share) || '';
589
+ shareBrowser.path = result.path || '';
590
+ shareBrowser.entries = result.entries;
591
+ shareBrowser.truncated = Boolean(result.truncated);
592
+ if (selectedPeer === env.from.streamId) {
593
+ renderDetails();
594
+ }
595
+ addMessage('command_response', env.from.name || env.from.streamId, 'listed ' + result.share + (result.path ? '/' + result.path : '') + ' (' + result.entries.length + ' entries)', env.ts);
596
+ return true;
597
+ }
598
+
599
+ if (pending && pending.command === 'get-file' && payload.ok && result && result.name) {
600
+ addMessage('command_response', env.from.name || env.from.streamId, 'sending ' + result.name + ' from ' + result.share + '/' + result.path, env.ts);
601
+ return true;
602
+ }
603
+
604
+ if (pending && payload.ok === false && payload.error) {
605
+ addMessage('command_response', env.from.name || env.from.streamId, pending.command + ' failed: ' + payload.error, env.ts);
606
+ return true;
607
+ }
608
+
609
+ return false;
610
+ }
611
+
612
+ function handleIncomingFileOffer(env) {
613
+ 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');
626
+ return true;
627
+ }
628
+
629
+ function handleIncomingFileChunk(env) {
630
+ const payload = env.payload || {};
631
+ const transfer = incomingTransfers.get(payload.transferId);
632
+ if (!transfer) return true;
633
+ const bytes = bytesFromBase64(payload.data || '');
634
+ transfer.parts[payload.index] = bytes;
635
+ transfer.receivedBytes += bytes.length;
636
+ return true;
637
+ }
638
+
639
+ async function handleIncomingFileComplete(env) {
640
+ const payload = env.payload || {};
641
+ const transfer = incomingTransfers.get(payload.transferId);
642
+ if (!transfer) return true;
643
+
644
+ try {
645
+ const missing = transfer.parts.some((part) => !part);
646
+ if (missing) throw new Error('file transfer incomplete');
647
+ if (transfer.receivedBytes !== transfer.size || payload.size !== transfer.size) {
648
+ throw new Error('file size mismatch');
649
+ }
650
+
651
+ const bytes = concatBytes(transfer.parts, transfer.size);
652
+ const sha = await sha256Hex(bytes);
653
+ if (transfer.sha256 && sha !== transfer.sha256) {
654
+ throw new Error('file checksum mismatch');
655
+ }
656
+ if (payload.sha256 && sha !== payload.sha256) {
657
+ throw new Error('file checksum mismatch');
658
+ }
659
+
660
+ const blob = new Blob([bytes], { type: transfer.mimeType });
661
+ const url = URL.createObjectURL(blob);
662
+ sendEnvelope('file_ack', {
663
+ transferId: payload.transferId,
664
+ ok: true,
665
+ name: transfer.name,
666
+ mimeType: transfer.mimeType,
667
+ kind: transfer.transferKind,
668
+ size: transfer.size,
669
+ sha256: sha
670
+ }, { to: env.from.streamId });
671
+
672
+ addMessage(
673
+ 'event',
674
+ env.from.name || env.from.streamId,
675
+ 'received ' + transfer.name,
676
+ env.ts,
677
+ transfer.transferKind + ' ready',
678
+ { downloadUrl: url, downloadName: transfer.name },
679
+ );
680
+ } catch (err) {
681
+ sendEnvelope('file_ack', {
682
+ transferId: payload.transferId,
683
+ ok: false,
684
+ error: err && err.message ? err.message : String(err)
685
+ }, { to: env.from.streamId });
686
+ addMessage('system', 'System', 'File receive failed: ' + (err && err.message ? err.message : String(err)), Date.now());
687
+ } finally {
688
+ incomingTransfers.delete(payload.transferId);
689
+ }
690
+
691
+ return true;
692
+ }
693
+
694
+ function handleIncomingFileAck(env) {
695
+ const payload = env.payload || {};
696
+ addMessage('event', env.from.name || env.from.streamId, (payload.ok ? 'delivery confirmed for ' : 'delivery failed for ') + (payload.name || payload.transferId), env.ts);
697
+ return true;
698
+ }
699
+
280
700
  // ── Connection ─────────────────────────────────────────────────────────
281
701
  async function doConnect() {
282
702
  const room = inpRoom.value.trim();
@@ -294,9 +714,9 @@ input:focus,select:focus{border-color:var(--accent)}
294
714
  };
295
715
 
296
716
  // Save to localStorage
297
- localStorage.setItem('clawd_room', room);
298
- localStorage.setItem('clawd_password', inpPassword.value.trim());
299
- localStorage.setItem('clawd_name', name);
717
+ localStorage.setItem('ninja_room', room);
718
+ localStorage.setItem('ninja_password', inpPassword.value.trim());
719
+ localStorage.setItem('ninja_name', name);
300
720
 
301
721
  statusEl.textContent = 'Connecting...';
302
722
  btnConnect.disabled = true;
@@ -311,13 +731,7 @@ input:focus,select:focus{border-color:var(--accent)}
311
731
  sdk.addEventListener('peerConnected', (e) => {
312
732
  const uuid = e.detail?.uuid || 'unknown';
313
733
  const sid = e.detail?.streamID || uuid;
314
- if (!peers.has(sid)) {
315
- peers.set(sid, { uuid, identity: null, skills: [], status: 'online', connected: true });
316
- } else {
317
- const p = peers.get(sid);
318
- p.uuid = uuid;
319
- p.connected = true;
320
- }
734
+ upsertPeer(sid, uuid, null);
321
735
  renderPeers();
322
736
  });
323
737
 
@@ -327,8 +741,19 @@ input:focus,select:focus{border-color:var(--accent)}
327
741
  const announce = createEnvelope('announce', {
328
742
  skills: ['dashboard-ui', 'chat'],
329
743
  status: 'online',
330
- version: '0.1.0',
331
- topics: ['events', 'bots', 'support']
744
+ version: DASHBOARD_VERSION,
745
+ topics: ['events', 'bots', 'support'],
746
+ agent: {
747
+ 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'],
750
+ asks: [
751
+ { name: 'status', description: 'Ask connected bots for current status' },
752
+ { name: 'peers', description: 'Ask connected bots for peer lists' },
753
+ { name: 'capabilities', description: 'Ask a selected bot what it can do' },
754
+ { name: 'shares', description: 'Ask a selected bot which shared folders it exposes' }
755
+ ]
756
+ }
332
757
  });
333
758
  try { sdk.sendData(announce, { UUID: uuid }); } catch(err) {}
334
759
  });
@@ -340,10 +765,9 @@ input:focus,select:focus{border-color:var(--accent)}
340
765
  if (!env) return;
341
766
 
342
767
  const sender = env.from.streamId;
768
+ if (!sender || sender === myStreamId) return;
343
769
  // Update peer info
344
- if (!peers.has(sender)) {
345
- peers.set(sender, { uuid, identity: env.from, skills: [], status: 'online', connected: true });
346
- }
770
+ upsertPeer(sender, uuid, env.from);
347
771
 
348
772
  switch (env.type) {
349
773
  case 'announce': {
@@ -352,12 +776,16 @@ input:focus,select:focus{border-color:var(--accent)}
352
776
  p.identity = env.from;
353
777
  p.skills = env.payload?.skills || [];
354
778
  p.status = env.payload?.status || 'online';
779
+ p.statusDetail = env.payload?.statusDetail || '';
780
+ p.topics = env.payload?.topics || [];
781
+ p.version = env.payload?.version || '';
782
+ p.agentProfile = env.payload?.agent || null;
355
783
  }
356
784
  addMessage('announce', env.from.name || sender,
357
785
  `joined as ${env.from.role || '?'} with skills: ${(env.payload?.skills || []).join(', ')}`,
358
786
  env.ts);
359
787
  renderPeers();
360
- renderSkills();
788
+ renderDetails();
361
789
  break;
362
790
  }
363
791
  case 'skill_update': {
@@ -365,12 +793,14 @@ input:focus,select:focus{border-color:var(--accent)}
365
793
  if (p) {
366
794
  p.skills = env.payload?.skills || p.skills;
367
795
  p.status = env.payload?.status || p.status;
796
+ p.statusDetail = env.payload?.statusDetail || p.statusDetail || '';
797
+ p.agentProfile = env.payload?.agent || p.agentProfile || null;
368
798
  }
369
799
  addMessage('event', env.from.name || sender,
370
800
  `status: ${env.payload?.status || '?'}${env.payload?.statusDetail ? ' - ' + env.payload.statusDetail : ''}`,
371
801
  env.ts);
372
802
  renderPeers();
373
- renderSkills();
803
+ renderDetails();
374
804
  break;
375
805
  }
376
806
  case 'chat':
@@ -391,9 +821,23 @@ input:focus,select:focus{border-color:var(--accent)}
391
821
  env.ts);
392
822
  break;
393
823
  case 'command_response':
394
- addMessage('command_response', env.from.name || sender,
395
- JSON.stringify(env.payload, null, 2),
396
- env.ts);
824
+ if (!handleCommandResponse(env)) {
825
+ addMessage('command_response', env.from.name || sender,
826
+ JSON.stringify(env.payload, null, 2),
827
+ env.ts);
828
+ }
829
+ break;
830
+ case 'file_offer':
831
+ handleIncomingFileOffer(env);
832
+ break;
833
+ case 'file_chunk':
834
+ handleIncomingFileChunk(env);
835
+ break;
836
+ case 'file_complete':
837
+ void handleIncomingFileComplete(env);
838
+ break;
839
+ case 'file_ack':
840
+ handleIncomingFileAck(env);
397
841
  break;
398
842
  case 'ping':
399
843
  // Auto-respond with pong
@@ -426,7 +870,7 @@ input:focus,select:focus{border-color:var(--accent)}
426
870
  // Find by uuid if no streamID
427
871
  let key = sid;
428
872
  if (!key) {
429
- for (const [k, v] of peers) { if (v.uuid === uuid) { key = k; break; } }
873
+ key = getPeerKeyByUuid(uuid);
430
874
  }
431
875
  if (key && peers.has(key)) {
432
876
  const p = peers.get(key);
@@ -434,6 +878,7 @@ input:focus,select:focus{border-color:var(--accent)}
434
878
  p.connected = false;
435
879
  addMessage('system', name, 'disconnected', Date.now());
436
880
  renderPeers();
881
+ renderDetails();
437
882
  }
438
883
  });
439
884
 
@@ -503,6 +948,7 @@ input:focus,select:focus{border-color:var(--accent)}
503
948
  inpRoom.disabled = false;
504
949
  inpPassword.disabled = false;
505
950
  renderPeers();
951
+ renderDetails();
506
952
  addMessage('system', 'System', 'Disconnected', Date.now());
507
953
  }
508
954
 
@@ -519,7 +965,7 @@ input:focus,select:focus{border-color:var(--accent)}
519
965
  switch (cmd) {
520
966
  case 'help':
521
967
  addMessage('system', 'System',
522
- 'Commands: /help, /peers, /status, /history [n], /health, /clear, /dm <peer> <msg>',
968
+ 'Commands: /help, /peers, /status, /history [n], /health, /clear, /dm <peer> <msg>, /cmd <peer> <command> [json], /plan <peer> <request>, /review <peer> <request>, /approve <peer> <request>, /respond <peer> <requestId> [json], /profile [peer], /capabilities [peer], /inbox [peer], /shares [peer], /ls <peer> <share> [path], /get <peer> <share> <path>',
523
969
  Date.now());
524
970
  return;
525
971
  case 'peers':
@@ -543,13 +989,97 @@ input:focus,select:focus{border-color:var(--accent)}
543
989
  messagesEl.innerHTML = '';
544
990
  messages.length = 0;
545
991
  return;
546
- case 'dm':
992
+ case 'dm': {
547
993
  if (parts.length < 3) { addMessage('system', 'System', 'Usage: /dm <peer_stream_id> <message>', Date.now()); return; }
548
994
  const target = parts[1];
549
995
  const msg = parts.slice(2).join(' ');
550
996
  sendEnvelope('chat', { text: msg }, { to: target });
551
997
  addMessage('chat', myIdentity.name + ' -> ' + target, msg, Date.now());
552
998
  return;
999
+ }
1000
+ case 'cmd': {
1001
+ const target = parts[1] || selectedPeer;
1002
+ if (!target || parts.length < 3) {
1003
+ addMessage('system', 'System', 'Usage: /cmd <peer_stream_id> <command> [json]', Date.now());
1004
+ return;
1005
+ }
1006
+ const command = parts[2];
1007
+ let args = undefined;
1008
+ if (parts.length > 3) {
1009
+ const raw = parts.slice(3).join(' ');
1010
+ try { args = JSON.parse(raw); } catch(_err) { args = raw; }
1011
+ }
1012
+ sendEnvelope('command', { command, args }, { to: target });
1013
+ addMessage('command', myIdentity.name + ' -> ' + target, 'CMD: ' + command + (args !== undefined ? ' ' + JSON.stringify(args) : ''), Date.now());
1014
+ return;
1015
+ }
1016
+ case 'shares': {
1017
+ const target = parts[1] || selectedPeer;
1018
+ if (!target) {
1019
+ addMessage('system', 'System', 'Select a peer or use /shares <peer_stream_id>', Date.now());
1020
+ return;
1021
+ }
1022
+ sendPeerCommand(target, 'shares');
1023
+ return;
1024
+ }
1025
+ case 'ls': {
1026
+ const target = parts[1] || selectedPeer;
1027
+ if (!target || parts.length < 3) {
1028
+ addMessage('system', 'System', 'Usage: /ls <peer_stream_id> <share> [path]', Date.now());
1029
+ return;
1030
+ }
1031
+ requestShareListing(target, parts[2], parts.slice(3).join(' '));
1032
+ return;
1033
+ }
1034
+ case 'get': {
1035
+ const target = parts[1] || selectedPeer;
1036
+ if (!target || parts.length < 4) {
1037
+ addMessage('system', 'System', 'Usage: /get <peer_stream_id> <share> <path>', Date.now());
1038
+ return;
1039
+ }
1040
+ requestSharedFile(target, parts[2], parts.slice(3).join(' '));
1041
+ return;
1042
+ }
1043
+ case 'plan':
1044
+ case 'review':
1045
+ case 'approve': {
1046
+ const target = parts[1] || selectedPeer;
1047
+ if (!target || parts.length < 3) {
1048
+ addMessage('system', 'System', 'Usage: /' + cmd + ' <peer_stream_id> <request>', Date.now());
1049
+ return;
1050
+ }
1051
+ const request = parts.slice(2).join(' ');
1052
+ sendPeerCommand(target, cmd, { request });
1053
+ return;
1054
+ }
1055
+ case 'respond': {
1056
+ const target = parts[1] || selectedPeer;
1057
+ if (!target || parts.length < 3) {
1058
+ addMessage('system', 'System', 'Usage: /respond <peer_stream_id> <requestId> [json]', Date.now());
1059
+ return;
1060
+ }
1061
+ const requestId = parts[2];
1062
+ let result = null;
1063
+ if (parts.length > 3) {
1064
+ const raw = parts.slice(3).join(' ');
1065
+ try { result = JSON.parse(raw); } catch (_err) { result = raw; }
1066
+ }
1067
+ sendEnvelope('command_response', { requestId, ok: true, result }, { to: target });
1068
+ addMessage('command', myIdentity.name + ' -> ' + target, 'RESP: ' + requestId + ' ' + JSON.stringify(result), Date.now());
1069
+ return;
1070
+ }
1071
+ case 'profile':
1072
+ case 'whoami':
1073
+ case 'capabilities':
1074
+ case 'inbox': {
1075
+ const target = parts[1] || selectedPeer;
1076
+ if (!target) {
1077
+ addMessage('system', 'System', 'Select a peer or use /' + cmd + ' <peer_stream_id>', Date.now());
1078
+ return;
1079
+ }
1080
+ sendPeerCommand(target, cmd);
1081
+ return;
1082
+ }
553
1083
  default:
554
1084
  addMessage('system', 'System', 'Unknown command: /' + cmd + '. Try /help', Date.now());
555
1085
  return;
@@ -558,14 +1088,22 @@ input:focus,select:focus{border-color:var(--accent)}
558
1088
 
559
1089
  // Regular chat message
560
1090
  if (!connected) { addMessage('system', 'System', 'Not connected', Date.now()); return; }
561
- const target = chatTarget.value || null;
562
- sendEnvelope('chat', { text: text }, target ? { to: target } : undefined);
563
- addMessage('chat', myIdentity.name + (target ? ' -> ' + target : ''), text, Date.now());
1091
+ const broadcastTarget = chatTarget.value || null;
1092
+ sendEnvelope('chat', { text: text }, broadcastTarget ? { to: broadcastTarget } : undefined);
1093
+ addMessage('chat', myIdentity.name + (broadcastTarget ? ' -> ' + broadcastTarget : ''), text, Date.now());
564
1094
  }
565
1095
 
566
1096
  function sendEnvelope(type, payload, opts) {
567
1097
  if (!sdk || !connected) return;
568
1098
  const env = createEnvelope(type, payload, opts);
1099
+ if (type === 'command') {
1100
+ pendingRequests.set(env.id, {
1101
+ command: payload && payload.command,
1102
+ target: opts && opts.to ? opts.to : null,
1103
+ args: payload && payload.args !== undefined ? payload.args : undefined,
1104
+ createdAt: env.ts
1105
+ });
1106
+ }
569
1107
  try {
570
1108
  if (opts && opts.to) {
571
1109
  // Find the peer's UUID for targeted send
@@ -590,7 +1128,8 @@ input:focus,select:focus{border-color:var(--accent)}
590
1128
  chatInput.addEventListener('keydown', (e) => {
591
1129
  if (e.key === 'Enter') { e.preventDefault(); handleInput(); }
592
1130
  });
593
- chatTarget.addEventListener('change', () => { selectedPeer = chatTarget.value; renderPeers(); });
1131
+ chatTarget.addEventListener('change', () => { selectedPeer = chatTarget.value; renderPeers(); renderDetails(); });
1132
+ renderDetails();
594
1133
 
595
1134
  // Auto-connect if room is provided via URL params
596
1135
  if (params.get('room') && params.get('autoconnect') !== 'false') {