vibedate 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,22 +1,103 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- connectProfile,
4
- loadProfile,
5
3
  runMcp
6
- } from "./chunk-64ME4THO.js";
4
+ } from "./chunk-5ZJIPMU6.js";
7
5
  import {
8
6
  CANDIDATES,
7
+ LIVE_NOTICE,
8
+ TOPIC_PREFIX,
9
+ canShareLive,
10
+ connectProfile,
11
+ grantLiveConsent,
9
12
  league,
13
+ leagueIndex,
14
+ loadPeers,
15
+ loadProfile,
10
16
  matches,
11
- readUsage
12
- } from "./chunk-AU7FN2LY.js";
17
+ parseFrame,
18
+ readUsage,
19
+ startDiscovery
20
+ } from "./chunk-6NWF2VD7.js";
13
21
 
14
22
  // src/cli.ts
15
- import { pathToFileURL } from "url";
16
- import process from "process";
23
+ import readline from "readline";
24
+ import "url";
25
+ import process2 from "process";
26
+
27
+ // src/pairing.ts
28
+ function createPairing() {
29
+ const queue = [];
30
+ const matchCbs = /* @__PURE__ */ new Set();
31
+ let current;
32
+ const emit = (link) => {
33
+ for (const cb of matchCbs) cb(link);
34
+ };
35
+ const watch = (link) => {
36
+ link.onClose(() => {
37
+ if (current === link) {
38
+ current = void 0;
39
+ const nextUp = queue.shift();
40
+ if (nextUp !== void 0) {
41
+ current = nextUp;
42
+ emit(current);
43
+ } else {
44
+ emit(void 0);
45
+ }
46
+ } else {
47
+ const idx = queue.indexOf(link);
48
+ if (idx >= 0) queue.splice(idx, 1);
49
+ }
50
+ });
51
+ };
52
+ return {
53
+ get available() {
54
+ return queue.length;
55
+ },
56
+ current() {
57
+ return current;
58
+ },
59
+ add(link) {
60
+ watch(link);
61
+ if (current === void 0) {
62
+ current = link;
63
+ emit(link);
64
+ } else {
65
+ queue.push(link);
66
+ }
67
+ },
68
+ next() {
69
+ if (current !== void 0) {
70
+ current.close();
71
+ current = void 0;
72
+ }
73
+ const nextUp = queue.shift();
74
+ if (nextUp !== void 0) {
75
+ current = nextUp;
76
+ }
77
+ emit(current);
78
+ return current;
79
+ },
80
+ open(handle2) {
81
+ const idx = queue.findIndex((l) => l.hello.handle === handle2);
82
+ if (idx < 0) return void 0;
83
+ const link = queue.splice(idx, 1)[0];
84
+ if (current !== void 0) {
85
+ current.close();
86
+ current = void 0;
87
+ }
88
+ current = link;
89
+ emit(current);
90
+ return current;
91
+ },
92
+ onMatch(cb) {
93
+ matchCbs.add(cb);
94
+ }
95
+ };
96
+ }
17
97
 
18
98
  // src/server.ts
19
99
  import http from "http";
100
+ import { makeEvent, notify as vibeCoreNotify } from "@pooriaarab/vibe-core";
20
101
 
21
102
  // src/web-app-html.ts
22
103
  var webAppHtml = `<!DOCTYPE html>
@@ -408,6 +489,49 @@ var webAppHtml = `<!DOCTYPE html>
408
489
  .stage{ padding: 22px 18px 80px; }
409
490
  header.topbar{ padding: 13px 16px; flex-wrap: wrap; }
410
491
  }
492
+
493
+ /* ---- live A/V (browser WebRTC over the local signaling relay) ---- */
494
+ .live-panel{
495
+ position: fixed; left: 18px; bottom: 18px; z-index: 45;
496
+ background: linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
497
+ border: 1px solid var(--border-2); border-radius: 16px; box-shadow: var(--shadow-2);
498
+ padding: 12px 14px; min-width: 220px; max-width: 260px; display: none;
499
+ }
500
+ .live-panel.is-open{ display: block; animation: rise var(--dur-med) var(--ease-out); }
501
+ .live-panel .lp-title{ font-size: .78rem; font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
502
+ .live-panel .lp-dot{ width: 7px; height: 7px; border-radius: 50%; background: var(--coral); box-shadow: 0 0 0 3px rgba(255,122,104,.18); flex-shrink: 0; }
503
+ .live-row{ display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 0; border-top: 1px solid var(--border); }
504
+ .live-row:first-of-type{ border-top: 0; }
505
+ .live-row .h{ font-size: .84rem; font-weight: 600; }
506
+ .live-row .s{ font-size: .7rem; color: var(--muted-2); }
507
+ .live-row .vbtn{
508
+ border: 0; border-radius: 9px; padding: 7px 12px; font-weight: 700; font-size: .76rem;
509
+ background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
510
+ transition: transform var(--dur-fast) var(--ease-out), filter var(--dur-fast) ease;
511
+ flex-shrink: 0;
512
+ }
513
+ .live-row .vbtn:hover{ filter: brightness(1.06); }
514
+ .live-row .vbtn:active{ transform: scale(.95); }
515
+ .live-row .vbtn:disabled{ opacity: .4; cursor: not-allowed; }
516
+ .video-modal{
517
+ position: fixed; inset: 0; z-index: 70;
518
+ display: flex; align-items: center; justify-content: center; gap: 18px; flex-wrap: wrap;
519
+ background: rgba(12,7,15,.78); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
520
+ opacity: 0; pointer-events: none; transition: opacity var(--dur-med) ease;
521
+ }
522
+ .video-modal.is-open{ opacity: 1; pointer-events: auto; }
523
+ .video-modal .vtile{ display: flex; flex-direction: column; align-items: center; gap: 8px; }
524
+ .video-modal video{
525
+ width: min(42vw, 560px); aspect-ratio: 4 / 3; background: #000; border-radius: 16px;
526
+ border: 1px solid var(--border-2); box-shadow: var(--shadow-3); object-fit: cover;
527
+ }
528
+ .video-modal .vlabel{ font-size: .8rem; color: var(--muted); font-weight: 600; }
529
+ .video-modal .vhangup{
530
+ position: absolute; bottom: 28px; left: 50%; transform: translateX(-50%);
531
+ border: 0; border-radius: 999px; padding: 12px 22px; font-weight: 800; font-size: .9rem;
532
+ background: var(--danger); color: #2a0a0c;
533
+ }
534
+ .video-modal .vhangup:hover{ filter: brightness(1.06); }
411
535
  </style>
412
536
  </head>
413
537
  <body>
@@ -552,6 +676,17 @@ var webAppHtml = `<!DOCTYPE html>
552
676
  </div>
553
677
  </div>
554
678
 
679
+ <aside class="live-panel" id="livePanel" aria-label="Live same-league peers">
680
+ <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live same-league peers</div>
681
+ <div id="liveRows"></div>
682
+ </aside>
683
+
684
+ <div class="video-modal" id="videoModal" role="dialog" aria-modal="true" aria-label="Video call">
685
+ <div class="vtile"><video id="remoteVideo" autoplay playsinline></video><div class="vlabel" id="remoteLabel">remote</div></div>
686
+ <div class="vtile"><video id="localVideo" autoplay playsinline muted></video><div class="vlabel">you</div></div>
687
+ <button class="vhangup" id="hangupBtn" type="button">Hang up</button>
688
+ </div>
689
+
555
690
  <script>
556
691
  (function(){
557
692
  "use strict";
@@ -833,10 +968,252 @@ var webAppHtml = `<!DOCTYPE html>
833
968
 
834
969
  })();
835
970
  </script>
971
+
972
+ <script>
973
+ (function(){
974
+ "use strict";
975
+ /* ---- Live A/V: browser WebRTC over the local server's signaling relay ----
976
+ * The browser owns the RTCPeerConnection (mic/camera capture, DTLS, SRTP);
977
+ * the local server only ferries rtc-offer / rtc-answer / rtc-ice frames
978
+ * between this browser and the remote peer's PeerLink. No media bytes touch
979
+ * the server, and no native WebRTC dep ships with the CLI. */
980
+
981
+ var livePanel = document.getElementById("livePanel");
982
+ var liveRows = document.getElementById("liveRows");
983
+ var videoModal = document.getElementById("videoModal");
984
+ var localVideo = document.getElementById("localVideo");
985
+ var remoteVideo = document.getElementById("remoteVideo");
986
+ var remoteLabel = document.getElementById("remoteLabel");
987
+ var hangupBtn = document.getElementById("hangupBtn");
988
+
989
+ // One in-flight call's state. remoteHandle === null means idle.
990
+ var rtc = { pc: null, localStream: null, remoteHandle: null, role: null };
991
+ var knownPeers = [];
992
+ var idleIdx = 0;
993
+
994
+ function rtcConfig(){
995
+ // A public STUN server crosses most NATs. No TURN in v0 \u2014 symmetric NATs
996
+ // won't connect, but signaling still completes and the call fails open.
997
+ return { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
998
+ }
999
+ function sleep(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }
1000
+
1001
+ // GET /live/signal?handle=X with a client-side abort so we never pile up
1002
+ // concurrent long-polls; on abort/error the caller just retries.
1003
+ function fetchSignal(handle, timeoutMs){
1004
+ var ctrl = new AbortController();
1005
+ var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
1006
+ return fetch("/live/signal?handle=" + encodeURIComponent(handle), { signal: ctrl.signal })
1007
+ .then(function(r){ clearTimeout(t); return r; })
1008
+ .catch(function(e){ clearTimeout(t); throw e; });
1009
+ }
1010
+
1011
+ async function postSignal(handle, frame){
1012
+ try {
1013
+ await fetch("/live/signal", {
1014
+ method: "POST",
1015
+ headers: { "content-type": "application/json" },
1016
+ body: JSON.stringify({ handle: handle, frame: frame })
1017
+ });
1018
+ } catch(e){ /* best effort \u2014 a dropped signal just slows the handshake */ }
1019
+ }
1020
+
1021
+ function showVideoModal(remoteHandle){
1022
+ remoteLabel.textContent = remoteHandle;
1023
+ videoModal.classList.add("is-open");
1024
+ }
1025
+
1026
+ function attachTracks(pc, stream){
1027
+ stream.getTracks().forEach(function(t){ pc.addTrack(t, stream); });
1028
+ }
1029
+
1030
+ // Offerer path: the user clicked our Video button.
1031
+ async function startCallAsOfferer(handle){
1032
+ rtc.role = "offerer";
1033
+ rtc.remoteHandle = handle;
1034
+ rtc.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
1035
+ localVideo.srcObject = rtc.localStream;
1036
+ var pc = new RTCPeerConnection(rtcConfig());
1037
+ rtc.pc = pc;
1038
+ pc.onicecandidate = onIce;
1039
+ pc.ontrack = function(e){ remoteVideo.srcObject = e.streams[0]; };
1040
+ attachTracks(pc, rtc.localStream);
1041
+ var offer = await pc.createOffer();
1042
+ await pc.setLocalDescription(offer);
1043
+ await postSignal(handle, { t: "rtc-offer", sdp: offer.sdp });
1044
+ showVideoModal(handle);
1045
+ pollSignal(handle);
1046
+ }
1047
+
1048
+ // Answerer path: we received the peer's offer via the relay.
1049
+ async function answerIncomingOffer(handle, sdp){
1050
+ rtc.role = "answerer";
1051
+ rtc.remoteHandle = handle;
1052
+ rtc.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
1053
+ localVideo.srcObject = rtc.localStream;
1054
+ var pc = new RTCPeerConnection(rtcConfig());
1055
+ rtc.pc = pc;
1056
+ pc.onicecandidate = onIce;
1057
+ pc.ontrack = function(e){ remoteVideo.srcObject = e.streams[0]; };
1058
+ attachTracks(pc, rtc.localStream);
1059
+ await pc.setRemoteDescription({ type: "offer", sdp: sdp });
1060
+ var answer = await pc.createAnswer();
1061
+ await pc.setLocalDescription(answer);
1062
+ await postSignal(handle, { t: "rtc-answer", sdp: answer.sdp });
1063
+ showVideoModal(handle);
1064
+ pollSignal(handle);
1065
+ }
1066
+
1067
+ function onIce(e){
1068
+ if (!rtc.remoteHandle) return;
1069
+ if (e.candidate) {
1070
+ // Trickle ICE: ship each candidate the moment it is gathered.
1071
+ postSignal(rtc.remoteHandle, { t: "rtc-ice", candidate: e.candidate.candidate });
1072
+ } else {
1073
+ // Gathering complete \u2014 send the end-of-candidates marker.
1074
+ postSignal(rtc.remoteHandle, { t: "rtc-ice", candidate: "" });
1075
+ }
1076
+ }
1077
+
1078
+ // Active long-poll loop for the peer we're in a call with. Drains answer +
1079
+ // ICE candidates until the call ends.
1080
+ async function pollSignal(handle){
1081
+ while (rtc.remoteHandle === handle && rtc.pc && rtc.pc.connectionState !== "closed") {
1082
+ var res;
1083
+ try { res = await fetchSignal(handle, 5000); }
1084
+ catch(e){ await sleep(500); continue; }
1085
+ if (!res || res.status !== 200) { await sleep(500); continue; }
1086
+ var data = await res.json();
1087
+ var f = data && data.frame;
1088
+ if (!f) continue; // timed out empty
1089
+ await handleIncoming(handle, f);
1090
+ }
1091
+ }
1092
+
1093
+ async function handleIncoming(handle, f){
1094
+ // An offer arriving with no active PC makes us the answerer.
1095
+ if (!rtc.pc) {
1096
+ if (f.t === "rtc-offer") { await answerIncomingOffer(handle, f.sdp); }
1097
+ return;
1098
+ }
1099
+ if (f.t === "rtc-answer") {
1100
+ try { await rtc.pc.setRemoteDescription({ type: "answer", sdp: f.sdp }); } catch(e){}
1101
+ } else if (f.t === "rtc-ice") {
1102
+ try { await rtc.pc.addIceCandidate({ candidate: f.candidate }); } catch(e){}
1103
+ }
1104
+ }
1105
+
1106
+ // Idle listener: when not in a call, watch known peers round-robin so an
1107
+ // incoming offer (the peer clicked THEIR Video button) is noticed and answered.
1108
+ async function idleLoop(){
1109
+ while (true) {
1110
+ if (rtc.remoteHandle || knownPeers.length === 0) { await sleep(1000); continue; }
1111
+ var peer = knownPeers[idleIdx % knownPeers.length];
1112
+ idleIdx++;
1113
+ try {
1114
+ var res = await fetchSignal(peer.handle, 3000);
1115
+ if (res && res.status === 200) {
1116
+ var data = await res.json();
1117
+ var f = data && data.frame;
1118
+ if (f && f.t === "rtc-offer" && !rtc.pc && !rtc.remoteHandle) {
1119
+ await answerIncomingOffer(peer.handle, f.sdp);
1120
+ }
1121
+ }
1122
+ } catch(e){ /* abort/timeout \u2014 loop to next peer */ }
1123
+ }
1124
+ }
1125
+ idleLoop();
1126
+
1127
+ function hangup(){
1128
+ try { if (rtc.pc) rtc.pc.close(); } catch(e){}
1129
+ rtc.pc = null;
1130
+ rtc.remoteHandle = null;
1131
+ rtc.role = null;
1132
+ if (rtc.localStream) {
1133
+ rtc.localStream.getTracks().forEach(function(t){ t.stop(); });
1134
+ rtc.localStream = null;
1135
+ }
1136
+ localVideo.srcObject = null;
1137
+ remoteVideo.srcObject = null;
1138
+ videoModal.classList.remove("is-open");
1139
+ }
1140
+ hangupBtn.addEventListener("click", hangup);
1141
+
1142
+ // Render the live-peers panel with a Video button each.
1143
+ async function refreshPeers(){
1144
+ try {
1145
+ var res = await fetch("/api/live/peers");
1146
+ if (res.status !== 200) return;
1147
+ var data = await res.json();
1148
+ var peers = (data && data.peers) || [];
1149
+ knownPeers = peers;
1150
+ if (peers.length === 0) { livePanel.classList.remove("is-open"); return; }
1151
+ livePanel.classList.add("is-open");
1152
+ liveRows.innerHTML = "";
1153
+ peers.forEach(function(p){
1154
+ var row = document.createElement("div");
1155
+ row.className = "live-row";
1156
+ var who = document.createElement("div");
1157
+ var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle;
1158
+ var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
1159
+ who.appendChild(h); who.appendChild(s);
1160
+ var btn = document.createElement("button");
1161
+ btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Video";
1162
+ btn.addEventListener("click", function(){
1163
+ if (rtc.remoteHandle) return; // already in a call
1164
+ btn.disabled = true;
1165
+ startCallAsOfferer(p.handle).catch(function(){ btn.disabled = false; });
1166
+ });
1167
+ row.appendChild(who); row.appendChild(btn);
1168
+ liveRows.appendChild(row);
1169
+ });
1170
+ } catch(e){}
1171
+ }
1172
+ refreshPeers();
1173
+ setInterval(refreshPeers, 4000);
1174
+ })();
1175
+ </script>
836
1176
  </body>
837
1177
  </html>`;
838
1178
 
839
1179
  // src/server.ts
1180
+ function createLiveBridge() {
1181
+ const boxes = /* @__PURE__ */ new Map();
1182
+ const bridge = {
1183
+ get peers() {
1184
+ return [...boxes.values()].map((m) => m.link.hello);
1185
+ },
1186
+ addLink(link) {
1187
+ const handle2 = link.hello.handle;
1188
+ boxes.set(handle2, { link, incoming: [] });
1189
+ link.onSignal((f) => {
1190
+ const mb = boxes.get(handle2);
1191
+ if (mb) mb.incoming.push(f);
1192
+ });
1193
+ link.onClose(() => {
1194
+ const cur = boxes.get(handle2);
1195
+ if (cur && cur.link === link) boxes.delete(handle2);
1196
+ });
1197
+ },
1198
+ sendSignal(handle2, frame) {
1199
+ boxes.get(handle2)?.link.sendSignal(frame);
1200
+ },
1201
+ async pollSignal(handle2, timeoutMs) {
1202
+ const mb = boxes.get(handle2);
1203
+ if (!mb) return null;
1204
+ if (mb.incoming.length > 0) return mb.incoming.shift() ?? null;
1205
+ const deadline = Date.now() + timeoutMs;
1206
+ while (Date.now() < deadline) {
1207
+ await new Promise((r) => setTimeout(r, 100));
1208
+ const cur = boxes.get(handle2);
1209
+ if (!cur) return null;
1210
+ if (cur.incoming.length > 0) return cur.incoming.shift() ?? null;
1211
+ }
1212
+ return null;
1213
+ }
1214
+ };
1215
+ return bridge;
1216
+ }
840
1217
  function currentState(dir) {
841
1218
  const p = loadProfile(dir);
842
1219
  if (!p) return { connected: false, candidates: [] };
@@ -914,22 +1291,126 @@ async function handle(req, res, opts) {
914
1291
  sendJson(res, 200, profileToState(profile));
915
1292
  return;
916
1293
  }
1294
+ if (req.method === "POST" && pathname === "/api/match") {
1295
+ const body = await readBody(req);
1296
+ let parsed = {};
1297
+ if (body.trim() !== "") {
1298
+ try {
1299
+ parsed = JSON.parse(body);
1300
+ } catch {
1301
+ sendJson(res, 400, { error: "invalid JSON body" });
1302
+ return;
1303
+ }
1304
+ }
1305
+ const candidateHandle = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
1306
+ if (candidateHandle === "") {
1307
+ sendJson(res, 400, { error: "missing candidate handle" });
1308
+ return;
1309
+ }
1310
+ const profile = loadProfile(opts.dir);
1311
+ if (!profile) {
1312
+ sendJson(res, 409, { error: "not connected" });
1313
+ return;
1314
+ }
1315
+ const candidate = CANDIDATES.find((c) => c.handle === candidateHandle);
1316
+ if (!candidate) {
1317
+ sendJson(res, 404, { error: "unknown candidate" });
1318
+ return;
1319
+ }
1320
+ const matched = candidate.league === profile.league;
1321
+ if (matched) {
1322
+ const sink = opts.notify ?? vibeCoreNotify;
1323
+ try {
1324
+ sink(
1325
+ makeEvent("match", profile.harness, process.cwd(), {
1326
+ summary: `matched with ${candidate.handle} - SAME LEAGUE`,
1327
+ handle: candidate.handle,
1328
+ league: profile.league
1329
+ })
1330
+ );
1331
+ } catch {
1332
+ }
1333
+ }
1334
+ sendJson(res, 200, { matched, handle: candidate.handle, league: candidate.league });
1335
+ return;
1336
+ }
1337
+ if (req.method === "GET" && pathname === "/api/live/peers") {
1338
+ const live = opts.live;
1339
+ sendJson(res, 200, { peers: live ? live.peers : [] });
1340
+ return;
1341
+ }
1342
+ if (req.method === "GET" && pathname === "/live/signal") {
1343
+ const live = opts.live;
1344
+ if (!live) {
1345
+ sendJson(res, 200, { frame: null, reason: "live-not-attached" });
1346
+ return;
1347
+ }
1348
+ const handle2 = url.searchParams.get("handle") ?? "";
1349
+ if (handle2 === "") {
1350
+ sendJson(res, 400, { error: "missing handle" });
1351
+ return;
1352
+ }
1353
+ const frame = await live.pollSignal(handle2, 25e3);
1354
+ if (req.destroyed || res.writableEnded) return;
1355
+ sendJson(res, 200, { frame });
1356
+ return;
1357
+ }
1358
+ if (req.method === "POST" && pathname === "/live/signal") {
1359
+ const live = opts.live;
1360
+ if (!live) {
1361
+ sendJson(res, 400, { error: "live-not-attached" });
1362
+ return;
1363
+ }
1364
+ const body = await readBody(req);
1365
+ let parsed = {};
1366
+ try {
1367
+ parsed = JSON.parse(body);
1368
+ } catch {
1369
+ sendJson(res, 400, { error: "invalid JSON body" });
1370
+ return;
1371
+ }
1372
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
1373
+ if (handle2 === "") {
1374
+ sendJson(res, 400, { error: "missing handle" });
1375
+ return;
1376
+ }
1377
+ const reParsed = parseFrame(JSON.stringify(parsed["frame"]));
1378
+ if (reParsed === null || reParsed.t !== "rtc-offer" && reParsed.t !== "rtc-answer" && reParsed.t !== "rtc-ice") {
1379
+ sendJson(res, 400, { error: "invalid rtc frame" });
1380
+ return;
1381
+ }
1382
+ live.sendSignal(handle2, reParsed);
1383
+ sendJson(res, 200, { ok: true });
1384
+ return;
1385
+ }
917
1386
  sendJson(res, 404, { error: "not found" });
918
1387
  }
919
1388
 
920
1389
  // src/cli.ts
921
- var VERSION = "0.1.0";
1390
+ var VERSION = "0.2.0";
922
1391
  function parsePort(raw) {
923
1392
  const n = Number(raw);
924
1393
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
925
1394
  return n;
926
1395
  }
927
1396
  function parseArgs(argv) {
928
- let out = { command: null, port: void 0 };
1397
+ let out = { command: null, port: void 0, live: false, dating: false };
929
1398
  for (let i = 0; i < argv.length; i++) {
930
1399
  const a = argv[i];
931
- if (a === "--version" || a === "-v") return { command: "version", port: void 0 };
932
- if (a === "--help" || a === "-h") return { command: "help", port: void 0 };
1400
+ if (a === "--version" || a === "-v") {
1401
+ return { command: "version", port: void 0, live: false, dating: false };
1402
+ }
1403
+ if (a === "--help" || a === "-h") {
1404
+ return { command: "help", port: void 0, live: false, dating: false };
1405
+ }
1406
+ if (a === "--live") {
1407
+ out = { ...out, live: true };
1408
+ continue;
1409
+ }
1410
+ if (a === "--dating") {
1411
+ out = { ...out, dating: true };
1412
+ continue;
1413
+ }
933
1414
  if (a === "--port") {
934
1415
  const next = argv[i + 1];
935
1416
  if (next !== void 0) {
@@ -945,7 +1426,7 @@ function parseArgs(argv) {
945
1426
  continue;
946
1427
  }
947
1428
  if (a.startsWith("-")) continue;
948
- const known = a === "connect" || a === "matches" || a === "open" || a === "mcp" || a === "help" ? a : null;
1429
+ const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "mcp" || a === "help" ? a : null;
949
1430
  if (known !== null && out.command === null) {
950
1431
  out = { ...out, command: known };
951
1432
  }
@@ -956,70 +1437,264 @@ function leagueLabel(name) {
956
1437
  return name === "below-1M" ? "below 1M (not yet in a league)" : `${name} League`;
957
1438
  }
958
1439
  async function cmdConnect() {
959
- const harness = process.env["VIBEDATING_HARNESS"] ?? "claude-code";
960
- const handle2 = process.env["VIBEDATING_HANDLE"] ?? "@you";
1440
+ const harness = process2.env["VIBEDATING_HARNESS"] ?? "claude-code";
1441
+ const handle2 = process2.env["VIBEDATING_HANDLE"] ?? "@you";
961
1442
  const snapshot = await readUsage(harness);
962
1443
  const profile = connectProfile(snapshot, handle2);
963
1444
  const lg = league(snapshot.totalTokens);
964
- process.stdout.write("\n");
965
- process.stdout.write(` ${leagueLabel(lg.name)}
1445
+ process2.stdout.write("\n");
1446
+ process2.stdout.write(` ${leagueLabel(lg.name)}
966
1447
  `);
967
- process.stdout.write(` handle: ${profile.handle} \xB7 harness: ${profile.harness}
1448
+ process2.stdout.write(` handle: ${profile.handle} \xB7 harness: ${profile.harness}
968
1449
  `);
969
- process.stdout.write(
1450
+ process2.stdout.write(
970
1451
  ` verification: ${profile.verified ? "verified (read-only OAuth)" : "self-reported"}
971
1452
  `
972
1453
  );
973
- process.stdout.write("\n");
974
- process.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n\n");
1454
+ process2.stdout.write("\n");
1455
+ process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n\n");
975
1456
  return 0;
976
1457
  }
977
- async function cmdMatches() {
1458
+ async function cmdMatches(live) {
978
1459
  const profile = loadProfile();
979
1460
  if (!profile) {
980
- process.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
1461
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
981
1462
  return 1;
982
1463
  }
1464
+ if (live && !canShareLive()) {
1465
+ process2.stderr.write(
1466
+ "Live matching is off (consent required). Opt in: `vibedating discover --live`.\n"
1467
+ );
1468
+ return 1;
1469
+ }
1470
+ const myIdx = leagueIndex(profile.league);
1471
+ const livePeers = canShareLive() ? loadPeers().filter((p) => {
1472
+ const idx = leagueIndex(p.league);
1473
+ return idx >= 0 && Math.abs(idx - myIdx) <= 1;
1474
+ }) : [];
1475
+ if (livePeers.length > 0) {
1476
+ process2.stdout.write(`Your league: ${leagueLabel(profile.league)}
1477
+ `);
1478
+ process2.stdout.write(
1479
+ `${livePeers.length} live peer${livePeers.length === 1 ? "" : "s"} in range (discovered over the DHT):
1480
+
1481
+ `
1482
+ );
1483
+ for (const p of livePeers) {
1484
+ process2.stdout.write(` ${p.handle.padEnd(28)} ${p.league} \xB7 ${p.harness}
1485
+ `);
1486
+ }
1487
+ process2.stdout.write("\n");
1488
+ return 0;
1489
+ }
1490
+ if (live) {
1491
+ process2.stdout.write("No live peers discovered yet. Run `vibedating discover` and keep it on.\n");
1492
+ return 0;
1493
+ }
983
1494
  const list = matches(profile.league, CANDIDATES);
984
- process.stdout.write(`Your league: ${leagueLabel(profile.league)}
1495
+ process2.stdout.write(`Your league: ${leagueLabel(profile.league)}
985
1496
  `);
986
- process.stdout.write(`${list.length} candidate${list.length === 1 ? "" : "s"} in range:
1497
+ process2.stdout.write(`${list.length} candidate${list.length === 1 ? "" : "s"} in range (local demo pool):
987
1498
 
988
1499
  `);
989
1500
  if (list.length === 0) {
990
- process.stdout.write(" (no candidates in range)\n");
1501
+ process2.stdout.write(" (no candidates in range)\n");
991
1502
  return 0;
992
1503
  }
993
1504
  for (const c of list) {
994
- process.stdout.write(` ${c.handle.padEnd(28)} ${c.league}
1505
+ process2.stdout.write(` ${c.handle.padEnd(28)} ${c.league}
995
1506
  `);
996
1507
  }
997
- process.stdout.write("\n");
1508
+ process2.stdout.write("\n");
998
1509
  return 0;
999
1510
  }
1000
- async function cmdOpen(port) {
1001
- const { url } = await startServer({ port });
1002
- process.stdout.write(`
1003
- vibedating local web app \u2192 ${url}
1511
+ async function cmdDiscover(live) {
1512
+ const profile = loadProfile();
1513
+ if (!profile) {
1514
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
1515
+ return 1;
1516
+ }
1517
+ if (live && !canShareLive()) grantLiveConsent();
1518
+ if (!canShareLive()) {
1519
+ process2.stderr.write(
1520
+ "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"
1521
+ );
1522
+ return 1;
1523
+ }
1524
+ const hello = {
1525
+ handle: profile.handle,
1526
+ league: profile.league,
1527
+ harness: profile.harness
1528
+ };
1529
+ process2.stdout.write("\n");
1530
+ process2.stdout.write(` ${LIVE_NOTICE}
1531
+ `);
1532
+ const session = await startDiscovery({
1533
+ hello,
1534
+ onPeer: (peer, isNew) => {
1535
+ process2.stdout.write(
1536
+ ` + ${peer.handle} (${peer.league} \xB7 ${peer.harness})${isNew ? " \u2190 new match" : ""}
1537
+ `
1538
+ );
1539
+ }
1540
+ });
1541
+ process2.stdout.write(
1542
+ ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
1543
+ `
1544
+ );
1545
+ process2.stdout.write(" listening for same-league peers\u2026 (Ctrl+C to stop)\n\n");
1546
+ await new Promise((resolve) => {
1547
+ process2.once("SIGINT", () => resolve());
1548
+ process2.once("SIGTERM", () => resolve());
1549
+ });
1550
+ process2.stdout.write("\n leaving the swarm\u2026\n");
1551
+ await session.close();
1552
+ process2.stdout.write(
1553
+ ` ${session.peers.size} peer${session.peers.size === 1 ? "" : "s"} discovered \xB7 saved to ~/.vibedating/peers.json
1004
1554
 
1555
+ `
1556
+ );
1557
+ return 0;
1558
+ }
1559
+ async function cmdOpen(port) {
1560
+ const profile = loadProfile();
1561
+ let live;
1562
+ let session;
1563
+ if (profile) {
1564
+ if (!canShareLive()) grantLiveConsent();
1565
+ live = createLiveBridge();
1566
+ const hello = {
1567
+ handle: profile.handle,
1568
+ league: profile.league,
1569
+ harness: profile.harness
1570
+ };
1571
+ process2.stdout.write(`
1572
+ ${LIVE_NOTICE}
1573
+ `);
1574
+ session = await startDiscovery({ hello, onLink: (link) => live.addLink(link) });
1575
+ }
1576
+ const started = await startServer({ port, live });
1577
+ process2.stdout.write(`
1578
+ vibedating local web app \u2192 ${started.url}
1579
+ `);
1580
+ if (live) {
1581
+ process2.stdout.write(" \u2022 live A/V (video) available for connected same-league peers\n");
1582
+ } else {
1583
+ process2.stdout.write(" \u2022 connect first (`vibedating connect`) to enable live video\n");
1584
+ }
1585
+ process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n");
1586
+ process2.stdout.write(" (Ctrl+C to stop)\n\n");
1587
+ await new Promise((resolve) => {
1588
+ process2.once("SIGINT", () => resolve());
1589
+ process2.once("SIGTERM", () => resolve());
1590
+ });
1591
+ process2.stdout.write("\n shutting down\u2026\n");
1592
+ if (session) await session.close();
1593
+ await new Promise((resolve) => started.server.close(() => resolve()));
1594
+ return 0;
1595
+ }
1596
+ async function cmdLive(dating) {
1597
+ const profile = loadProfile();
1598
+ if (!profile) {
1599
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
1600
+ return 1;
1601
+ }
1602
+ if (!canShareLive()) grantLiveConsent();
1603
+ if (!canShareLive()) {
1604
+ process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
1605
+ return 1;
1606
+ }
1607
+ const hello = {
1608
+ handle: profile.handle,
1609
+ league: profile.league,
1610
+ harness: profile.harness
1611
+ };
1612
+ process2.stdout.write("\n");
1613
+ process2.stdout.write(` ${LIVE_NOTICE}
1614
+ `);
1615
+ process2.stdout.write(
1616
+ dating ? " dating mode \u2014 /open <handle> to pick a peer\n" : " omegle mode \u2014 /next to roll a new peer\n"
1617
+ );
1618
+ const pairing = createPairing();
1619
+ pairing.onMatch((link) => {
1620
+ if (link === void 0) {
1621
+ process2.stdout.write(" \xB7 idle \u2014 no peer right now\n");
1622
+ return;
1623
+ }
1624
+ process2.stdout.write(
1625
+ ` \xB7 matched ${link.hello.handle} (${link.hello.league} \xB7 ${link.hello.harness})
1626
+ `
1627
+ );
1628
+ link.onMessage((m) => {
1629
+ process2.stdout.write(` <${link.hello.handle}> ${m.text}
1005
1630
  `);
1006
- process.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n");
1007
- process.stdout.write(" (Ctrl+C to stop)\n\n");
1631
+ });
1632
+ });
1633
+ const session = await startDiscovery({
1634
+ hello,
1635
+ onLink: (link) => pairing.add(link)
1636
+ });
1637
+ process2.stdout.write(
1638
+ ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
1639
+ `
1640
+ );
1641
+ process2.stdout.write(" type to chat \xB7 /next \xB7 /open <handle> \xB7 /quit\n");
1642
+ process2.stdout.write(" video chat: live A/V runs in the web app \u2014 run `vibedating open`\n\n");
1643
+ const rl = readline.createInterface({ input: process2.stdin, terminal: false });
1644
+ const stop = () => {
1645
+ rl.close();
1646
+ };
1647
+ process2.once("SIGINT", stop);
1648
+ process2.once("SIGTERM", stop);
1649
+ for await (const line of rl) {
1650
+ const text = line.trim();
1651
+ if (text === "/quit") break;
1652
+ if (text === "/next") {
1653
+ pairing.next();
1654
+ continue;
1655
+ }
1656
+ if (text.startsWith("/open ")) {
1657
+ const handle2 = text.slice("/open ".length).trim();
1658
+ if (pairing.open(handle2) === void 0) {
1659
+ process2.stdout.write(` \xB7 no available peer "${handle2}"
1660
+ `);
1661
+ }
1662
+ continue;
1663
+ }
1664
+ if (text === "") continue;
1665
+ const cur2 = pairing.current();
1666
+ if (cur2 !== void 0) {
1667
+ cur2.send(text);
1668
+ } else {
1669
+ process2.stdout.write(" \xB7 no peer yet \u2014 waiting for a match\u2026\n");
1670
+ }
1671
+ }
1672
+ process2.removeListener("SIGINT", stop);
1673
+ process2.removeListener("SIGTERM", stop);
1674
+ const cur = pairing.current();
1675
+ if (cur !== void 0) cur.close();
1676
+ process2.stdout.write("\n leaving the swarm\u2026\n");
1677
+ await session.close();
1678
+ process2.stdout.write("\n");
1008
1679
  return 0;
1009
1680
  }
1010
1681
  var HELP = `vibedating ${VERSION} \u2014 dating by tokens (local-first)
1011
1682
 
1012
1683
  Usage:
1013
1684
  vibedating connect Read your usage, compute + print your league
1014
- vibedating matches List candidates in your league
1685
+ vibedating matches [--live] List candidates in your league (live peers if any)
1686
+ vibedating discover [--live] Find live same-league peers over the DHT (opt-in)
1687
+ vibedating live [--dating] Live chat with same-league peers (omegle /next, or --dating pick)
1015
1688
  vibedating open [--port N] Serve the local web app (default: random port)
1689
+ + live A/V video with connected same-league peers
1016
1690
  vibedating mcp Run the stdio MCP server (profile, matches)
1017
1691
  vibedating --version
1018
1692
  vibedating --help
1019
1693
 
1020
1694
  Privacy:
1021
1695
  Raw token usage is read and stored LOCALLY (~/.vibedating). Only the league
1022
- bucket is ever shared. v0 has no central directory \u2014 the pool is a seeded demo.
1696
+ bucket is ever shared. Live discovery (off by default) shares ONLY your
1697
+ handle + league + harness with same-league peers \u2014 opt in with --live.
1023
1698
 
1024
1699
  Env:
1025
1700
  VIBEDATING_TOKENS=<n> Self-report a token count (e.g. 23400000 or 12M)
@@ -1030,42 +1705,46 @@ async function main(argv) {
1030
1705
  const parsed = parseArgs(argv);
1031
1706
  switch (parsed.command) {
1032
1707
  case "version":
1033
- process.stdout.write(`vibedating ${VERSION}
1708
+ process2.stdout.write(`vibedating ${VERSION}
1034
1709
  `);
1035
1710
  return 0;
1036
1711
  case "help":
1037
1712
  case null:
1038
- process.stdout.write(HELP);
1713
+ process2.stdout.write(HELP);
1039
1714
  return 0;
1040
1715
  case "connect":
1041
1716
  return cmdConnect();
1042
1717
  case "matches":
1043
- return cmdMatches();
1718
+ return cmdMatches(parsed.live);
1719
+ case "discover":
1720
+ return cmdDiscover(parsed.live);
1044
1721
  case "open":
1045
1722
  return cmdOpen(parsed.port);
1723
+ case "live":
1724
+ return cmdLive(parsed.dating);
1046
1725
  case "mcp":
1047
1726
  await runMcp();
1048
1727
  return 0;
1049
1728
  }
1050
1729
  }
1051
- var entryUrl = process.argv[1];
1730
+ var entryUrl = process2.argv[1];
1052
1731
  if (entryUrl !== void 0) {
1053
1732
  let isMain = false;
1054
1733
  try {
1055
- isMain = import.meta.url === pathToFileURL(entryUrl).href;
1734
+ isMain = true;
1056
1735
  } catch {
1057
1736
  isMain = false;
1058
1737
  }
1059
1738
  if (isMain) {
1060
- void main(process.argv.slice(2)).then(
1739
+ void main(process2.argv.slice(2)).then(
1061
1740
  (code) => {
1062
- if (code !== 0) process.exit(code);
1741
+ if (code !== 0) process2.exit(code);
1063
1742
  },
1064
1743
  (err) => {
1065
- process.stderr.write(err instanceof Error ? `${err.stack ?? err.message}
1744
+ process2.stderr.write(err instanceof Error ? `${err.stack ?? err.message}
1066
1745
  ` : `${String(err)}
1067
1746
  `);
1068
- process.exit(1);
1747
+ process2.exit(1);
1069
1748
  }
1070
1749
  );
1071
1750
  }