vibedate 0.3.1 → 0.4.1

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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-OVO2CQ7X.js";
4
+ } from "./chunk-ZB56ZBAR.js";
5
5
  import {
6
6
  CANDIDATES,
7
7
  LIVE_NOTICE,
@@ -17,6 +17,7 @@ import {
17
17
  leagueTopic,
18
18
  leaguesWithin,
19
19
  loadBlocklist,
20
+ loadOrCreateIdentity,
20
21
  loadPeers,
21
22
  loadProfile,
22
23
  matches,
@@ -27,8 +28,9 @@ import {
27
28
  resolveHandle,
28
29
  sameHandle,
29
30
  saveHandle,
31
+ signHelloClaims,
30
32
  startDiscovery
31
- } from "./chunk-3VT4EOBX.js";
33
+ } from "./chunk-43IYNWES.js";
32
34
 
33
35
  // src/cli.ts
34
36
  import readline from "readline";
@@ -108,6 +110,7 @@ function createPairing() {
108
110
 
109
111
  // src/server.ts
110
112
  import http from "http";
113
+ import { randomUUID } from "crypto";
111
114
  import { makeEvent, notify as vibeCoreNotify } from "@pooriaarab/vibe-core";
112
115
 
113
116
  // src/web-app-html.ts
@@ -543,6 +546,80 @@ var webAppHtml = `<!DOCTYPE html>
543
546
  background: var(--danger); color: #2a0a0c;
544
547
  }
545
548
  .video-modal .vhangup:hover{ filter: brightness(1.06); }
549
+ .live-panel .lp-legend{ font-size: .66rem; color: var(--muted-2); margin-top: 8px; line-height: 1.4; }
550
+ .incoming-call{
551
+ position: fixed; inset: 0; z-index: 65;
552
+ display: flex; align-items: center; justify-content: center;
553
+ background: rgba(12,7,15,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
554
+ opacity: 0; pointer-events: none; transition: opacity var(--dur-med) ease;
555
+ }
556
+ .incoming-call.is-open{ opacity: 1; pointer-events: auto; }
557
+ .incoming-call .ic-card{
558
+ background: linear-gradient(165deg, var(--bg-card), var(--bg-card-2));
559
+ border: 1px solid var(--border-2); border-radius: 24px; box-shadow: var(--shadow-3);
560
+ padding: 30px 28px; max-width: 320px; text-align: center;
561
+ transform: scale(.85) translateY(10px); transition: transform var(--dur-med) var(--ease-bounce);
562
+ }
563
+ .incoming-call.is-open .ic-card{ transform: scale(1) translateY(0); }
564
+ .incoming-call h3{ margin: 0 0 6px; font-size: 1.15rem; font-weight: 800; word-break: break-all; }
565
+ .incoming-call p{ color: var(--muted); font-size: .85rem; margin: 0 0 18px; }
566
+ .incoming-call .ic-actions{ display: flex; gap: 10px; }
567
+
568
+ /* ---- live text chat (browser <-> PeerLink over /live/message) ---- */
569
+ .live-actions{ display: flex; gap: 6px; flex-shrink: 0; }
570
+ .live-row .cbtn{
571
+ border: 1px solid var(--border-2); border-radius: 9px; padding: 7px 12px;
572
+ font-weight: 700; font-size: .76rem; background: transparent; color: var(--muted);
573
+ transition: color var(--dur-fast) ease, border-color var(--dur-fast) ease, transform var(--dur-fast) var(--ease-out);
574
+ flex-shrink: 0;
575
+ }
576
+ .live-row .cbtn:hover{ color: var(--fg); border-color: var(--muted-2); }
577
+ .live-row .cbtn:active{ transform: scale(.95); }
578
+ .live-row .cbtn.has-unread{ color: var(--coral); border-color: var(--coral); }
579
+ .chat-panel{
580
+ position: fixed; right: 18px; bottom: 18px; z-index: 45;
581
+ width: min(320px, calc(100vw - 36px));
582
+ background: linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
583
+ border: 1px solid var(--border-2); border-radius: 16px; box-shadow: var(--shadow-2);
584
+ display: none; flex-direction: column; overflow: hidden;
585
+ }
586
+ .chat-panel.is-open{ display: flex; animation: rise var(--dur-med) var(--ease-out); }
587
+ .chat-panel .cp-head{
588
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
589
+ padding: 11px 14px; border-bottom: 1px solid var(--border);
590
+ }
591
+ .chat-panel .cp-title{ font-size: .84rem; font-weight: 700; word-break: break-all; }
592
+ .chat-panel .cp-sub{ font-size: .68rem; color: var(--muted-2); margin-top: 2px; }
593
+ .chat-panel .cp-close{
594
+ border: 0; background: transparent; color: var(--muted-2);
595
+ font-size: 1.15rem; line-height: 1; padding: 4px; flex-shrink: 0;
596
+ }
597
+ .chat-panel .cp-close:hover{ color: var(--fg); }
598
+ .chat-panel .cp-msgs{
599
+ padding: 12px 14px; min-height: 120px; max-height: 260px; overflow-y: auto;
600
+ display: flex; flex-direction: column; gap: 7px;
601
+ }
602
+ .chat-panel .cp-empty{ font-size: .76rem; color: var(--muted-2); text-align: center; margin: auto; }
603
+ .cp-msg{
604
+ max-width: 85%; padding: 7px 11px; border-radius: 13px;
605
+ font-size: .82rem; line-height: 1.4; word-break: break-word; white-space: pre-wrap;
606
+ }
607
+ .cp-msg.them{ align-self: flex-start; background: rgba(248,239,232,.07); border: 1px solid var(--border); }
608
+ .cp-msg.you{ align-self: flex-end; background: rgba(255,122,104,.16); border: 1px solid rgba(255,122,104,.32); }
609
+ .cp-msg.sys{ align-self: center; background: transparent; border: 0; color: var(--muted-2); font-size: .72rem; padding: 2px 6px; }
610
+ .chat-panel .cp-inputrow{ display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); }
611
+ .chat-panel .cp-inputrow input{
612
+ flex: 1; border: 1px solid var(--border-2); border-radius: 10px; background: rgba(0,0,0,.22);
613
+ color: var(--fg); padding: 9px 11px; font: inherit; font-size: .82rem; outline: none; min-width: 0;
614
+ }
615
+ .chat-panel .cp-inputrow input:focus{ border-color: var(--coral); }
616
+ .chat-panel .cp-send{
617
+ border: 0; border-radius: 10px; padding: 9px 14px; font-weight: 700; font-size: .8rem;
618
+ background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
619
+ transition: filter var(--dur-fast) ease, transform var(--dur-fast) var(--ease-out);
620
+ }
621
+ .chat-panel .cp-send:hover{ filter: brightness(1.06); }
622
+ .chat-panel .cp-send:active{ transform: scale(.95); }
546
623
  </style>
547
624
  </head>
548
625
  <body>
@@ -687,9 +764,36 @@ var webAppHtml = `<!DOCTYPE html>
687
764
  </div>
688
765
  </div>
689
766
 
690
- <aside class="live-panel" id="livePanel" aria-label="Live same-league peers">
691
- <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live same-league peers</div>
767
+ <aside class="live-panel" id="livePanel" aria-label="Live peers">
768
+ <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live peers</div>
692
769
  <div id="liveRows"></div>
770
+ <div class="lp-legend">&#10003; usage verified &middot; &#128273; identity verified &mdash; call someone, or wait for a call</div>
771
+ </aside>
772
+
773
+ <div class="incoming-call" id="incomingCall" role="dialog" aria-modal="true" aria-label="Incoming call">
774
+ <div class="ic-card">
775
+ <h3 id="incomingCaller">@peer</h3>
776
+ <p>is calling you &mdash; video chat</p>
777
+ <div class="ic-actions">
778
+ <button class="btn btn-ghost" id="declineBtn" type="button">Decline</button>
779
+ <button class="btn btn-primary" id="acceptBtn" type="button">Accept</button>
780
+ </div>
781
+ </div>
782
+ </div>
783
+
784
+ <aside class="chat-panel" id="chatPanel" aria-label="Text chat">
785
+ <div class="cp-head">
786
+ <div>
787
+ <div class="cp-title" id="chatTitle">@peer</div>
788
+ <div class="cp-sub" id="chatSub">live over the P2P link &middot; text only</div>
789
+ </div>
790
+ <button class="cp-close" id="chatClose" type="button" aria-label="Close chat">&times;</button>
791
+ </div>
792
+ <div class="cp-msgs" id="chatMsgs"></div>
793
+ <div class="cp-inputrow">
794
+ <input id="chatInput" type="text" maxlength="4000" placeholder="message&hellip;" autocomplete="off">
795
+ <button class="cp-send" id="chatSend" type="button">Send</button>
796
+ </div>
693
797
  </aside>
694
798
 
695
799
  <div class="video-modal" id="videoModal" role="dialog" aria-modal="true" aria-label="Video call">
@@ -996,9 +1100,15 @@ var webAppHtml = `<!DOCTYPE html>
996
1100
  var remoteVideo = document.getElementById("remoteVideo");
997
1101
  var remoteLabel = document.getElementById("remoteLabel");
998
1102
  var hangupBtn = document.getElementById("hangupBtn");
1103
+ var incomingCallEl = document.getElementById("incomingCall");
1104
+ var incomingCaller = document.getElementById("incomingCaller");
1105
+ var acceptBtn = document.getElementById("acceptBtn");
1106
+ var declineBtn = document.getElementById("declineBtn");
999
1107
 
1000
1108
  // One in-flight call's state. remoteHandle === null means idle.
1001
1109
  var rtc = { pc: null, localStream: null, remoteHandle: null, role: null };
1110
+ // An incoming offer awaiting the user's accept/decline (never auto-answered).
1111
+ var pendingOffer = null;
1002
1112
  var knownPeers = [];
1003
1113
  var idleIdx = 0;
1004
1114
 
@@ -1115,7 +1225,8 @@ var webAppHtml = `<!DOCTYPE html>
1115
1225
  }
1116
1226
 
1117
1227
  // Idle listener: when not in a call, watch known peers round-robin so an
1118
- // incoming offer (the peer clicked THEIR Video button) is noticed and answered.
1228
+ // incoming offer (the peer clicked THEIR Call button) is noticed. Offers are
1229
+ // never auto-answered \u2014 the caller is named and the user accepts or declines.
1119
1230
  async function idleLoop(){
1120
1231
  while (true) {
1121
1232
  if (rtc.remoteHandle || knownPeers.length === 0) { await sleep(1000); continue; }
@@ -1127,7 +1238,12 @@ var webAppHtml = `<!DOCTYPE html>
1127
1238
  var data = await res.json();
1128
1239
  var f = data && data.frame;
1129
1240
  if (f && f.t === "rtc-offer" && !rtc.pc && !rtc.remoteHandle) {
1130
- await answerIncomingOffer(peer.handle, f.sdp);
1241
+ // One prompt at a time: a re-offer from the SAME caller refreshes
1242
+ // the stored sdp; an offer from someone else while a prompt is up
1243
+ // is dropped (their call simply fails to connect).
1244
+ if (pendingOffer && pendingOffer.handle !== peer.handle) continue;
1245
+ pendingOffer = { handle: peer.handle, sdp: f.sdp };
1246
+ showIncomingPrompt(peer.handle);
1131
1247
  }
1132
1248
  }
1133
1249
  } catch(e){ /* abort/timeout \u2014 loop to next peer */ }
@@ -1135,6 +1251,22 @@ var webAppHtml = `<!DOCTYPE html>
1135
1251
  }
1136
1252
  idleLoop();
1137
1253
 
1254
+ function showIncomingPrompt(handle){
1255
+ incomingCaller.textContent = handle;
1256
+ incomingCallEl.classList.add("is-open");
1257
+ }
1258
+ function clearIncomingPrompt(){
1259
+ pendingOffer = null;
1260
+ incomingCallEl.classList.remove("is-open");
1261
+ }
1262
+ acceptBtn.addEventListener("click", function(){
1263
+ var p = pendingOffer;
1264
+ clearIncomingPrompt();
1265
+ if (!p) return;
1266
+ answerIncomingOffer(p.handle, p.sdp).catch(function(){ hangup(); });
1267
+ });
1268
+ declineBtn.addEventListener("click", clearIncomingPrompt);
1269
+
1138
1270
  function hangup(){
1139
1271
  try { if (rtc.pc) rtc.pc.close(); } catch(e){}
1140
1272
  rtc.pc = null;
@@ -1150,7 +1282,171 @@ var webAppHtml = `<!DOCTYPE html>
1150
1282
  }
1151
1283
  hangupBtn.addEventListener("click", hangup);
1152
1284
 
1153
- // Render the live-peers panel with a Video button each.
1285
+ /* ---- Live text chat: msg frames over the same PeerLinks, via /live/message ----
1286
+ * One long-poll loop per connected peer surfaces incoming texts immediately,
1287
+ * no matter which conversation is open. Outbound texts POST {handle, text};
1288
+ * the server re-validates through the frame allowlist and mints id/at. */
1289
+ var chatPanel = document.getElementById("chatPanel");
1290
+ var chatTitle = document.getElementById("chatTitle");
1291
+ var chatSub = document.getElementById("chatSub");
1292
+ var chatMsgs = document.getElementById("chatMsgs");
1293
+ var chatInput = document.getElementById("chatInput");
1294
+ var chatSend = document.getElementById("chatSend");
1295
+ var chatClose = document.getElementById("chatClose");
1296
+
1297
+ var chatWith = null; // handle of the open conversation (null = closed)
1298
+ var conversations = {}; // handle -> [{from:"you"|"them"|"sys", text}]
1299
+ var unread = {}; // handle -> count of unseen incoming messages
1300
+ var chatLoops = {}; // handle -> true while its poll loop is running
1301
+ var MAX_CHAT_KEPT = 200; // per-conversation local cap (mirrors the server)
1302
+
1303
+ function fetchMessage(handle, timeoutMs){
1304
+ var ctrl = new AbortController();
1305
+ var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
1306
+ return fetch("/live/message?handle=" + encodeURIComponent(handle), { signal: ctrl.signal })
1307
+ .then(function(r){ clearTimeout(t); return r; })
1308
+ .catch(function(e){ clearTimeout(t); throw e; });
1309
+ }
1310
+
1311
+ function postChat(handle, text){
1312
+ return fetch("/live/message", {
1313
+ method: "POST",
1314
+ headers: { "content-type": "application/json" },
1315
+ body: JSON.stringify({ handle: handle, text: text })
1316
+ });
1317
+ }
1318
+
1319
+ function peerKnown(handle){
1320
+ for (var i = 0; i < knownPeers.length; i++){ if (knownPeers[i].handle === handle) return true; }
1321
+ return false;
1322
+ }
1323
+
1324
+ function pushChat(handle, entry){
1325
+ var conv = conversations[handle] || (conversations[handle] = []);
1326
+ conv.push(entry);
1327
+ if (conv.length > MAX_CHAT_KEPT) conv.splice(0, conv.length - MAX_CHAT_KEPT);
1328
+ }
1329
+
1330
+ // One long-poll loop per connected peer: started when the peer is first
1331
+ // seen, stopped when it vanishes (its mailbox on the server is gone).
1332
+ function ensureChatLoop(handle){
1333
+ if (chatLoops[handle]) return;
1334
+ chatLoops[handle] = true;
1335
+ (async function(){
1336
+ while (peerKnown(handle)) {
1337
+ var res;
1338
+ try { res = await fetchMessage(handle, 30000); }
1339
+ catch(e){ await sleep(1000); continue; }
1340
+ if (!res || res.status !== 200) { await sleep(1000); continue; }
1341
+ var data = await res.json();
1342
+ var m = data && data.message;
1343
+ if (!m) continue; // timed out empty
1344
+ pushChat(handle, { from: "them", text: m.text });
1345
+ if (chatWith === handle) {
1346
+ renderChat();
1347
+ } else {
1348
+ unread[handle] = (unread[handle] || 0) + 1;
1349
+ renderLiveRows(knownPeers);
1350
+ }
1351
+ }
1352
+ delete chatLoops[handle];
1353
+ })();
1354
+ }
1355
+
1356
+ function renderChat(){
1357
+ chatMsgs.innerHTML = "";
1358
+ var conv = (chatWith && conversations[chatWith]) || [];
1359
+ if (conv.length === 0) {
1360
+ var empty = document.createElement("div");
1361
+ empty.className = "cp-empty";
1362
+ empty.textContent = "No messages yet \u2014 say hi.";
1363
+ chatMsgs.appendChild(empty);
1364
+ return;
1365
+ }
1366
+ conv.forEach(function(m){
1367
+ var el = document.createElement("div");
1368
+ el.className = "cp-msg " + (m.from === "you" ? "you" : m.from === "sys" ? "sys" : "them");
1369
+ // textContent only \u2014 a peer's text is never parsed as HTML.
1370
+ el.textContent = m.text;
1371
+ chatMsgs.appendChild(el);
1372
+ });
1373
+ chatMsgs.scrollTop = chatMsgs.scrollHeight;
1374
+ }
1375
+
1376
+ function openChat(handle){
1377
+ chatWith = handle;
1378
+ delete unread[handle];
1379
+ chatTitle.textContent = handle;
1380
+ chatSub.textContent = peerKnown(handle)
1381
+ ? "live over the P2P link \xB7 text only"
1382
+ : "peer offline \xB7 messages will not deliver";
1383
+ chatPanel.classList.add("is-open");
1384
+ renderChat();
1385
+ renderLiveRows(knownPeers);
1386
+ chatInput.focus();
1387
+ }
1388
+ chatClose.addEventListener("click", function(){
1389
+ chatWith = null;
1390
+ chatPanel.classList.remove("is-open");
1391
+ });
1392
+
1393
+ function sendChat(){
1394
+ var target = chatWith;
1395
+ var text = chatInput.value.trim();
1396
+ if (!target || text === "") return;
1397
+ chatInput.value = "";
1398
+ postChat(target, text).then(function(res){
1399
+ // Honest failure \u2014 never show a message as sent when it wasn't.
1400
+ pushChat(target, res && res.status === 200
1401
+ ? { from: "you", text: text }
1402
+ : { from: "sys", text: "(not delivered \u2014 peer offline?)" });
1403
+ if (chatWith === target) renderChat();
1404
+ }).catch(function(){
1405
+ pushChat(target, { from: "sys", text: "(not delivered \u2014 server unreachable)" });
1406
+ if (chatWith === target) renderChat();
1407
+ });
1408
+ }
1409
+ chatSend.addEventListener("click", sendChat);
1410
+ chatInput.addEventListener("keydown", function(e){ if (e.key === "Enter") sendChat(); });
1411
+
1412
+ // Render the live-peers rows: one per connected peer with its verification
1413
+ // marks, a Chat button (opens the conversation), and a Call button that
1414
+ // rings THAT peer specifically.
1415
+ function renderLiveRows(peers){
1416
+ liveRows.innerHTML = "";
1417
+ peers.forEach(function(p){
1418
+ var row = document.createElement("div");
1419
+ row.className = "live-row";
1420
+ var who = document.createElement("div");
1421
+ // Marks from the peer's hello, shown only when present: \u2713 usage
1422
+ // verified (real local logs), \u{1F511} identity-verified (signed hello).
1423
+ var marks = (p.verified === true ? " \u2713" : "") + (p.identityVerified === true ? " \u{1F511}" : "");
1424
+ var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle + marks;
1425
+ var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
1426
+ who.appendChild(h); who.appendChild(s);
1427
+ var actions = document.createElement("div");
1428
+ actions.className = "live-actions";
1429
+ var chatBtn = document.createElement("button");
1430
+ chatBtn.className = "cbtn" + (unread[p.handle] ? " has-unread" : "");
1431
+ chatBtn.type = "button";
1432
+ chatBtn.textContent = "Chat" + (unread[p.handle] ? " (" + unread[p.handle] + ")" : "");
1433
+ chatBtn.addEventListener("click", function(){ openChat(p.handle); });
1434
+ var btn = document.createElement("button");
1435
+ btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Call";
1436
+ btn.addEventListener("click", function(){
1437
+ if (rtc.remoteHandle || pendingOffer) return; // already in a call / prompt
1438
+ btn.disabled = true;
1439
+ // DIRECTED call: the rtc-offer is relayed only to this peer's handle.
1440
+ startCallAsOfferer(p.handle).catch(function(){ hangup(); });
1441
+ });
1442
+ actions.appendChild(chatBtn);
1443
+ actions.appendChild(btn);
1444
+ row.appendChild(who);
1445
+ row.appendChild(actions);
1446
+ liveRows.appendChild(row);
1447
+ });
1448
+ }
1449
+
1154
1450
  async function refreshPeers(){
1155
1451
  try {
1156
1452
  var res = await fetch("/api/live/peers");
@@ -1158,26 +1454,15 @@ var webAppHtml = `<!DOCTYPE html>
1158
1454
  var data = await res.json();
1159
1455
  var peers = (data && data.peers) || [];
1160
1456
  knownPeers = peers;
1457
+ peers.forEach(function(p){ ensureChatLoop(p.handle); });
1458
+ if (chatWith) {
1459
+ chatSub.textContent = peerKnown(chatWith)
1460
+ ? "live over the P2P link \xB7 text only"
1461
+ : "peer offline \xB7 messages will not deliver";
1462
+ }
1161
1463
  if (peers.length === 0) { livePanel.classList.remove("is-open"); return; }
1162
1464
  livePanel.classList.add("is-open");
1163
- liveRows.innerHTML = "";
1164
- peers.forEach(function(p){
1165
- var row = document.createElement("div");
1166
- row.className = "live-row";
1167
- var who = document.createElement("div");
1168
- var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle;
1169
- var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
1170
- who.appendChild(h); who.appendChild(s);
1171
- var btn = document.createElement("button");
1172
- btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Video";
1173
- btn.addEventListener("click", function(){
1174
- if (rtc.remoteHandle) return; // already in a call
1175
- btn.disabled = true;
1176
- startCallAsOfferer(p.handle).catch(function(){ btn.disabled = false; });
1177
- });
1178
- row.appendChild(who); row.appendChild(btn);
1179
- liveRows.appendChild(row);
1180
- });
1465
+ renderLiveRows(peers);
1181
1466
  } catch(e){}
1182
1467
  }
1183
1468
  refreshPeers();
@@ -1188,19 +1473,37 @@ var webAppHtml = `<!DOCTYPE html>
1188
1473
  </html>`;
1189
1474
 
1190
1475
  // src/server.ts
1476
+ var MAX_QUEUED_MESSAGES = 200;
1191
1477
  function createLiveBridge() {
1192
1478
  const boxes = /* @__PURE__ */ new Map();
1193
1479
  const bridge = {
1194
1480
  get peers() {
1195
- return [...boxes.values()].map((m) => m.link.hello);
1481
+ return [...boxes.values()].map((m) => {
1482
+ const h = m.link.hello;
1483
+ return {
1484
+ handle: h.handle,
1485
+ league: h.league,
1486
+ harness: h.harness,
1487
+ ...h.verified !== void 0 ? { verified: h.verified } : {},
1488
+ ...h.identityVerified !== void 0 ? { identityVerified: h.identityVerified } : {}
1489
+ };
1490
+ });
1196
1491
  },
1197
1492
  addLink(link) {
1198
1493
  const handle2 = link.hello.handle;
1199
- boxes.set(handle2, { link, incoming: [] });
1494
+ boxes.set(handle2, { link, incoming: [], messages: [] });
1200
1495
  link.onSignal((f) => {
1201
1496
  const mb = boxes.get(handle2);
1202
1497
  if (mb) mb.incoming.push(f);
1203
1498
  });
1499
+ link.onMessage((m) => {
1500
+ const mb = boxes.get(handle2);
1501
+ if (!mb) return;
1502
+ mb.messages.push(m);
1503
+ if (mb.messages.length > MAX_QUEUED_MESSAGES) {
1504
+ mb.messages.splice(0, mb.messages.length - MAX_QUEUED_MESSAGES);
1505
+ }
1506
+ });
1204
1507
  link.onClose(() => {
1205
1508
  const cur = boxes.get(handle2);
1206
1509
  if (cur && cur.link === link) boxes.delete(handle2);
@@ -1221,6 +1524,22 @@ function createLiveBridge() {
1221
1524
  if (cur.incoming.length > 0) return cur.incoming.shift() ?? null;
1222
1525
  }
1223
1526
  return null;
1527
+ },
1528
+ sendMessage(handle2, text) {
1529
+ boxes.get(handle2)?.link.send(text);
1530
+ },
1531
+ async pollMessage(handle2, timeoutMs) {
1532
+ const mb = boxes.get(handle2);
1533
+ if (!mb) return null;
1534
+ if (mb.messages.length > 0) return mb.messages.shift() ?? null;
1535
+ const deadline = Date.now() + timeoutMs;
1536
+ while (Date.now() < deadline) {
1537
+ await new Promise((r) => setTimeout(r, 100));
1538
+ const cur = boxes.get(handle2);
1539
+ if (!cur) return null;
1540
+ if (cur.messages.length > 0) return cur.messages.shift() ?? null;
1541
+ }
1542
+ return null;
1224
1543
  }
1225
1544
  };
1226
1545
  return bridge;
@@ -1394,11 +1713,62 @@ async function handle(req, res, opts) {
1394
1713
  sendJson(res, 200, { ok: true });
1395
1714
  return;
1396
1715
  }
1716
+ if (req.method === "GET" && pathname === "/live/message") {
1717
+ const live = opts.live;
1718
+ if (!live) {
1719
+ sendJson(res, 200, { message: null, reason: "live-not-attached" });
1720
+ return;
1721
+ }
1722
+ const handle2 = url.searchParams.get("handle") ?? "";
1723
+ if (handle2 === "") {
1724
+ sendJson(res, 400, { error: "missing handle" });
1725
+ return;
1726
+ }
1727
+ const message = await live.pollMessage(handle2, 25e3);
1728
+ if (req.destroyed || res.writableEnded) return;
1729
+ sendJson(res, 200, { message });
1730
+ return;
1731
+ }
1732
+ if (req.method === "POST" && pathname === "/live/message") {
1733
+ const live = opts.live;
1734
+ if (!live) {
1735
+ sendJson(res, 400, { error: "live-not-attached" });
1736
+ return;
1737
+ }
1738
+ const body = await readBody(req);
1739
+ let parsed = {};
1740
+ try {
1741
+ parsed = JSON.parse(body);
1742
+ } catch {
1743
+ sendJson(res, 400, { error: "invalid JSON body" });
1744
+ return;
1745
+ }
1746
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
1747
+ if (handle2 === "") {
1748
+ sendJson(res, 400, { error: "missing handle" });
1749
+ return;
1750
+ }
1751
+ const text = parsed["text"];
1752
+ if (typeof text !== "string") {
1753
+ sendJson(res, 400, { error: "missing text" });
1754
+ return;
1755
+ }
1756
+ const reParsed = parseFrame(
1757
+ JSON.stringify({ t: "msg", id: randomUUID(), text, at: Date.now() })
1758
+ );
1759
+ if (reParsed === null || reParsed.t !== "msg") {
1760
+ sendJson(res, 400, { error: "invalid message text" });
1761
+ return;
1762
+ }
1763
+ live.sendMessage(handle2, reParsed.text);
1764
+ sendJson(res, 200, { ok: true });
1765
+ return;
1766
+ }
1397
1767
  sendJson(res, 404, { error: "not found" });
1398
1768
  }
1399
1769
 
1400
1770
  // src/cli.ts
1401
- var VERSION = "0.3.1";
1771
+ var VERSION = "0.4.1";
1402
1772
  function parsePort(raw) {
1403
1773
  const n = Number(raw);
1404
1774
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -1489,6 +1859,21 @@ function parseArgs(argv) {
1489
1859
  function leagueLabel(name) {
1490
1860
  return name === "below-1M" ? "below 1M (not yet in a league)" : `${name} League`;
1491
1861
  }
1862
+ function formatTokens(n) {
1863
+ const trim = (v) => String(Math.round(v * 10) / 10);
1864
+ const abs = Math.abs(n);
1865
+ if (abs >= 1e9) return `${trim(n / 1e9)}B`;
1866
+ if (abs >= 1e6) return `${trim(n / 1e6)}M`;
1867
+ if (abs >= 1e3) return `${trim(n / 1e3)}k`;
1868
+ return String(n);
1869
+ }
1870
+ function verificationText(snapshot) {
1871
+ if (snapshot.source === "real") {
1872
+ return `verified: real usage \u2014 ${formatTokens(snapshot.totalTokens)} tokens from ${snapshot.harness} logs`;
1873
+ }
1874
+ if (snapshot.source === "self-report") return "self-reported (unverified)";
1875
+ return "demo (unverified)";
1876
+ }
1492
1877
  function discoveryScope(myLeague, any) {
1493
1878
  const names = any ? allLeagueNames() : leaguesWithin(myLeague, 1);
1494
1879
  const ordered = [myLeague, ...names.filter((n) => n !== myLeague)];
@@ -1510,21 +1895,38 @@ function peerDirection(myLeague, peerLeague, sameBullet = "+") {
1510
1895
  if (d < 0) return { bullet: "\u2193", qual: " \xB7 lower league" };
1511
1896
  return { bullet: sameBullet, qual: "" };
1512
1897
  }
1898
+ function buildHello(profile) {
1899
+ const claims = {
1900
+ handle: resolveHandle(),
1901
+ league: profile.league,
1902
+ harness: profile.harness,
1903
+ verified: profile.verified
1904
+ };
1905
+ return { ...claims, ...signHelloClaims(loadOrCreateIdentity(), claims) };
1906
+ }
1907
+ function usageMark(peer) {
1908
+ return peer.verified === true ? "\u2713" : "~";
1909
+ }
1910
+ function idMark(peer) {
1911
+ return peer.identityVerified === true ? " \u{1F511}" : "";
1912
+ }
1913
+ var MARKS_LEGEND = "marks: \u2713 usage verified (real local logs) \xB7 ~ unverified (self-report/demo/legacy) \xB7 \u{1F511} identity-verified (signed hello)";
1513
1914
  async function cmdConnect() {
1514
1915
  const harness = process2.env["VIBEDATING_HARNESS"] ?? "claude-code";
1515
1916
  const handle2 = resolveHandle();
1516
1917
  const snapshot = await readUsage(harness);
1517
1918
  const profile = connectProfile(snapshot, handle2);
1919
+ const identity = loadOrCreateIdentity();
1518
1920
  const lg = league(snapshot.totalTokens);
1519
1921
  process2.stdout.write("\n");
1520
1922
  process2.stdout.write(` ${leagueLabel(lg.name)}
1521
1923
  `);
1522
1924
  process2.stdout.write(` handle: ${profile.handle} \xB7 harness: ${profile.harness}
1523
1925
  `);
1524
- process2.stdout.write(
1525
- ` verification: ${profile.verified ? "verified (read-only OAuth)" : "self-reported"}
1526
- `
1527
- );
1926
+ process2.stdout.write(` verification: ${verificationText(snapshot)}
1927
+ `);
1928
+ process2.stdout.write(` identity: ed25519 ${identity.publicKeyHex.slice(0, 12)}\u2026 \u2014 signs your hello (\u{1F511})
1929
+ `);
1528
1930
  process2.stdout.write("\n");
1529
1931
  process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n\n");
1530
1932
  return 0;
@@ -1573,11 +1975,13 @@ async function cmdMatches(live) {
1573
1975
  `);
1574
1976
  process2.stdout.write(
1575
1977
  `${livePeers.length} live peer${livePeers.length === 1 ? "" : "s"} in range (discovered over the DHT):
1576
-
1577
1978
  `
1578
1979
  );
1980
+ process2.stdout.write(` ${MARKS_LEGEND}
1981
+
1982
+ `);
1579
1983
  for (const p of livePeers) {
1580
- process2.stdout.write(` ${p.handle.padEnd(28)} ${p.league} \xB7 ${p.harness}
1984
+ process2.stdout.write(` ${p.handle.padEnd(28)} ${p.league} \xB7 ${p.harness} ${usageMark(p)}${idMark(p)}
1581
1985
  `);
1582
1986
  }
1583
1987
  process2.stdout.write("\n");
@@ -1613,17 +2017,15 @@ async function cmdDiscover(live, any) {
1613
2017
  if (live && !canShareLive()) grantLiveConsent();
1614
2018
  if (!canShareLive()) {
1615
2019
  process2.stderr.write(
1616
- "Live discovery is off. It shares ONLY your handle + league + harness (never raw usage)\nwith same-league peers on the public DHT. Opt in: `vibedating discover --live`\n"
2020
+ "Live discovery is off. It shares ONLY your handle + league + harness + verified flag + identity pubkey\n(never raw usage) with same-league peers on the public DHT. Opt in: `vibedating discover --live`\n"
1617
2021
  );
1618
2022
  return 1;
1619
2023
  }
1620
- const hello = {
1621
- handle: resolveHandle(),
1622
- league: profile.league,
1623
- harness: profile.harness
1624
- };
2024
+ const hello = buildHello(profile);
1625
2025
  process2.stdout.write("\n");
1626
2026
  process2.stdout.write(` ${LIVE_NOTICE}
2027
+ `);
2028
+ process2.stdout.write(` ${MARKS_LEGEND}
1627
2029
  `);
1628
2030
  const { topics, acceptLeague } = discoveryScope(profile.league, any);
1629
2031
  const session = await startDiscovery({
@@ -1634,7 +2036,7 @@ async function cmdDiscover(live, any) {
1634
2036
  onPeer: (peer, isNew) => {
1635
2037
  const { bullet, qual } = peerDirection(profile.league, peer.league);
1636
2038
  process2.stdout.write(
1637
- ` ${bullet} ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness})${isNew ? " \u2190 new match" : ""}
2039
+ ` ${bullet} ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness}) ${usageMark(peer)}${idMark(peer)}${isNew ? " \u2190 new match" : ""}
1638
2040
  `
1639
2041
  );
1640
2042
  }
@@ -1664,7 +2066,7 @@ async function cmdDiscover(live, any) {
1664
2066
  );
1665
2067
  return 0;
1666
2068
  }
1667
- async function cmdOpen(port) {
2069
+ async function cmdOpen(port, any) {
1668
2070
  const profile = loadProfile();
1669
2071
  let live;
1670
2072
  let session;
@@ -1677,12 +2079,15 @@ async function cmdOpen(port) {
1677
2079
  process2.stdout.write(`
1678
2080
  ${LIVE_NOTICE}
1679
2081
  `);
1680
- const hello = {
1681
- handle: resolveHandle(),
1682
- league: profile.league,
1683
- harness: profile.harness
1684
- };
1685
- void startDiscovery({ hello, isBlocked: blockedChecker(), onLink: (link) => live.addLink(link) }).then((s) => {
2082
+ const hello = buildHello(profile);
2083
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
2084
+ void startDiscovery({
2085
+ hello,
2086
+ topics,
2087
+ acceptLeague,
2088
+ isBlocked: blockedChecker(),
2089
+ onLink: (link) => live.addLink(link)
2090
+ }).then((s) => {
1686
2091
  session = s;
1687
2092
  }).catch(() => {
1688
2093
  });
@@ -1691,9 +2096,11 @@ async function cmdOpen(port) {
1691
2096
  vibedating local web app \u2192 ${started.url}
1692
2097
  `);
1693
2098
  if (live) {
1694
- process2.stdout.write(" \u2022 live A/V (video) available for connected same-league peers\n");
2099
+ process2.stdout.write(
2100
+ any ? " \u2022 live video + chat available for connected peers (ANY league \u2014 --any)\n" : " \u2022 live video + chat available for connected peers (your league + adjacent; --any = everyone)\n"
2101
+ );
1695
2102
  } else {
1696
- process2.stdout.write(" \u2022 connect first (`vibedating connect`) to enable live video\n");
2103
+ process2.stdout.write(" \u2022 connect first (`vibedating connect`) to enable live video + chat\n");
1697
2104
  }
1698
2105
  process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n");
1699
2106
  process2.stdout.write(" (Ctrl+C to stop)\n\n");
@@ -1723,13 +2130,11 @@ async function cmdLive(dating, any, to) {
1723
2130
  process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
1724
2131
  return 1;
1725
2132
  }
1726
- const hello = {
1727
- handle: resolveHandle(),
1728
- league: profile.league,
1729
- harness: profile.harness
1730
- };
2133
+ const hello = buildHello(profile);
1731
2134
  process2.stdout.write("\n");
1732
2135
  process2.stdout.write(` ${LIVE_NOTICE}
2136
+ `);
2137
+ process2.stdout.write(` ${MARKS_LEGEND}
1733
2138
  `);
1734
2139
  process2.stdout.write(
1735
2140
  target !== null ? ` targeted \u2014 auto-opening ${target} when they connect (/quit to stop)
@@ -1743,7 +2148,7 @@ async function cmdLive(dating, any, to) {
1743
2148
  }
1744
2149
  const { qual } = peerDirection(profile.league, link.hello.league);
1745
2150
  process2.stdout.write(
1746
- ` \xB7 matched ${link.hello.handle} (${link.hello.league}${qual} \xB7 ${link.hello.harness})
2151
+ ` \xB7 matched ${link.hello.handle} (${link.hello.league}${qual} \xB7 ${link.hello.harness}) ${usageMark(link.hello)}${idMark(link.hello)}
1747
2152
  `
1748
2153
  );
1749
2154
  link.onMessage((m) => {
@@ -1761,7 +2166,7 @@ async function cmdLive(dating, any, to) {
1761
2166
  if (target !== null && !sameHandle(link.hello.handle, target)) {
1762
2167
  const { qual } = peerDirection(profile.league, link.hello.league);
1763
2168
  process2.stdout.write(
1764
- ` + ${link.hello.handle} (${link.hello.league}${qual}) \u2014 not your target
2169
+ ` + ${link.hello.handle} (${link.hello.league}${qual}) ${usageMark(link.hello)}${idMark(link.hello)} \u2014 not your target
1765
2170
  `
1766
2171
  );
1767
2172
  link.close();
@@ -1839,13 +2244,11 @@ async function cmdFind(targetArg, any) {
1839
2244
  process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
1840
2245
  return 1;
1841
2246
  }
1842
- const hello = {
1843
- handle: resolveHandle(),
1844
- league: profile.league,
1845
- harness: profile.harness
1846
- };
2247
+ const hello = buildHello(profile);
1847
2248
  process2.stdout.write("\n");
1848
2249
  process2.stdout.write(` ${LIVE_NOTICE}
2250
+ `);
2251
+ process2.stdout.write(` ${MARKS_LEGEND}
1849
2252
  `);
1850
2253
  process2.stdout.write(
1851
2254
  ` looking for ${target} in your league${any ? " (+ every league)" : " + adjacent"}\u2026
@@ -1862,11 +2265,15 @@ async function cmdFind(targetArg, any) {
1862
2265
  const { qual } = peerDirection(profile.league, peer.league);
1863
2266
  if (sameHandle(peer.handle, target)) {
1864
2267
  found = true;
1865
- process2.stdout.write(` \u2605 found ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness})
1866
- `);
2268
+ process2.stdout.write(
2269
+ ` \u2605 found ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness}) ${usageMark(peer)}${idMark(peer)}
2270
+ `
2271
+ );
1867
2272
  } else {
1868
- process2.stdout.write(` + ${peer.handle} (${peer.league}${qual}) \u2014 not your target
1869
- `);
2273
+ process2.stdout.write(
2274
+ ` + ${peer.handle} (${peer.league}${qual}) ${usageMark(peer)}${idMark(peer)} \u2014 not your target
2275
+ `
2276
+ );
1870
2277
  }
1871
2278
  }
1872
2279
  });
@@ -1956,8 +2363,9 @@ Usage:
1956
2363
  vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
1957
2364
  vibedating unblock <@handle> Remove a handle from the blocklist
1958
2365
  vibedating blocklist List blocked handles
1959
- vibedating open [--port N] Serve the local web app (default: random port)
1960
- + live A/V video with connected same-league peers
2366
+ vibedating open [--port N] [--any] Serve the local web app (default: random port)
2367
+ + live video + chat with connected peers
2368
+ (your league + adjacent; --any = every league)
1961
2369
  vibedating mcp Run the stdio MCP server (profile, matches)
1962
2370
  vibedating --version
1963
2371
  vibedating --help
@@ -1965,12 +2373,16 @@ Usage:
1965
2373
  Privacy:
1966
2374
  Raw token usage is read and stored LOCALLY (~/.vibedating). Only the league
1967
2375
  bucket is ever shared. Live discovery (off by default) shares ONLY your
1968
- handle + league + harness with same-league peers \u2014 opt in with --live.
2376
+ handle + league + harness + verified flag + identity pubkey (an ed25519 key
2377
+ generated on first connect, stored 0600 in ~/.vibedating/identity.json) with
2378
+ same-league peers \u2014 opt in with --live. Peers are marked: \u2713 usage verified
2379
+ (real local logs) \xB7 ~ unverified \xB7 \u{1F511} identity-verified (signed hello).
1969
2380
 
1970
2381
  Matching:
1971
- discover/live match your league + adjacent (\xB11) tiers by default, so thin
1972
- leagues and cross-league friends still connect. --any matches every league.
1973
- find / live --to look for one specific handle instead of the first random peer.
2382
+ discover/live/open match your league + adjacent (\xB11) tiers by default, so
2383
+ thin leagues and cross-league friends still connect. --any matches every
2384
+ league. find / live --to look for one specific handle instead of the first
2385
+ random peer.
1974
2386
 
1975
2387
  Env:
1976
2388
  VIBEDATING_TOKENS=<n> Self-report a token count (e.g. 23400000 or 12M)
@@ -2003,7 +2415,7 @@ async function main(argv) {
2003
2415
  case "discover":
2004
2416
  return cmdDiscover(parsed.live, parsed.any);
2005
2417
  case "open":
2006
- return cmdOpen(parsed.port);
2418
+ return cmdOpen(parsed.port, parsed.any);
2007
2419
  case "live":
2008
2420
  return cmdLive(parsed.dating, parsed.any, parsed.to);
2009
2421
  case "find":