vibedate 0.6.0 → 0.7.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.
@@ -1145,6 +1145,103 @@ async function createNostrPoolTransport(urls = DEFAULT_RELAYS) {
1145
1145
  };
1146
1146
  }
1147
1147
 
1148
+ // src/room.ts
1149
+ import { createHash as createHash3 } from "crypto";
1150
+ var ROOM_TOPIC_PREFIX = "vibedate-room:";
1151
+ function roomTopic(name) {
1152
+ return createHash3("sha256").update(`${ROOM_TOPIC_PREFIX}${name}`, "utf8").digest();
1153
+ }
1154
+ async function startRoom(opts) {
1155
+ const topic = roomTopic(opts.room);
1156
+ const entries = /* @__PURE__ */ new Map();
1157
+ const memberHellos = /* @__PURE__ */ new Map();
1158
+ const messageCbs = /* @__PURE__ */ new Set();
1159
+ const rosterCbs = /* @__PURE__ */ new Set();
1160
+ const signalCbs = /* @__PURE__ */ new Set();
1161
+ const fireRoster = () => {
1162
+ const snapshot = [...memberHellos.values()];
1163
+ for (const cb of rosterCbs) cb(snapshot);
1164
+ };
1165
+ let discovery;
1166
+ const ready = startDiscovery({
1167
+ hello: opts.hello,
1168
+ // ONE topic — the room's own. Rooms are intentionally cross-league, so
1169
+ // every member of the room is accepted regardless of advertised league.
1170
+ topics: [topic],
1171
+ acceptLeague: () => true,
1172
+ ...opts.isBlocked === void 0 ? {} : { isBlocked: opts.isBlocked },
1173
+ ...opts.bootstrap === void 0 ? {} : { bootstrap: opts.bootstrap },
1174
+ ...opts.stateDir === void 0 ? {} : { stateDir: opts.stateDir },
1175
+ ...opts.notify === void 0 ? {} : { notify: opts.notify },
1176
+ onLink: (link) => {
1177
+ const handle = link.hello.handle;
1178
+ entries.set(handle, { hello: link.hello, link });
1179
+ memberHellos.set(handle, link.hello);
1180
+ fireRoster();
1181
+ link.onMessage((m) => {
1182
+ for (const cb of messageCbs) cb({ from: handle, ...m });
1183
+ });
1184
+ link.onSignal((frame) => {
1185
+ for (const cb of signalCbs) cb(handle, frame);
1186
+ });
1187
+ link.onClose(() => {
1188
+ const cur = entries.get(handle);
1189
+ if (cur && cur.link === link) {
1190
+ entries.delete(handle);
1191
+ memberHellos.delete(handle);
1192
+ fireRoster();
1193
+ }
1194
+ });
1195
+ }
1196
+ }).then((s) => {
1197
+ discovery = s;
1198
+ return s.ready;
1199
+ });
1200
+ const session = {
1201
+ room: opts.room,
1202
+ topic,
1203
+ hello: opts.hello,
1204
+ members: memberHellos,
1205
+ ready,
1206
+ broadcast(text) {
1207
+ const reached = [];
1208
+ for (const [handle, entry] of entries) {
1209
+ entry.link.send(text);
1210
+ reached.push(handle);
1211
+ }
1212
+ return reached;
1213
+ },
1214
+ onMessage(cb) {
1215
+ messageCbs.add(cb);
1216
+ },
1217
+ onRoster(cb) {
1218
+ rosterCbs.add(cb);
1219
+ },
1220
+ onSignal(cb) {
1221
+ signalCbs.add(cb);
1222
+ },
1223
+ sendSignal(handle, frame) {
1224
+ entries.get(handle)?.link.sendSignal(frame);
1225
+ },
1226
+ linkFor(handle) {
1227
+ return entries.get(handle)?.link;
1228
+ },
1229
+ async close() {
1230
+ await ready.catch(() => void 0);
1231
+ for (const entry of entries.values()) {
1232
+ try {
1233
+ entry.link.close();
1234
+ } catch {
1235
+ }
1236
+ }
1237
+ entries.clear();
1238
+ memberHellos.clear();
1239
+ if (discovery !== void 0) await discovery.close();
1240
+ }
1241
+ };
1242
+ return session;
1243
+ }
1244
+
1148
1245
  // src/index.ts
1149
1246
  var LEAGUES = [
1150
1247
  { name: "1M", min: 1e6, max: 4999999 },
@@ -1320,6 +1417,9 @@ export {
1320
1417
  conversationTag,
1321
1418
  createNostrRelayLink,
1322
1419
  createNostrPoolTransport,
1420
+ ROOM_TOPIC_PREFIX,
1421
+ roomTopic,
1422
+ startRoom,
1323
1423
  LEAGUES,
1324
1424
  BELOW_LEAGUE,
1325
1425
  league,
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-VZXPYU2C.js";
5
+ } from "./chunk-HDHJLZZG.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Recognized top-level commands, plus the synthetic help/version. */
3
- type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'help' | 'version' | null;
3
+ type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'room' | 'help' | 'version' | null;
4
4
  interface ParsedArgs {
5
5
  readonly command: Command;
6
6
  /** Port for `open --port`; undefined means "let the OS pick". */
@@ -23,6 +23,8 @@ interface ParsedArgs {
23
23
  * auto-fallback-on-timeout is a follow-up. Default false.
24
24
  */
25
25
  readonly viaRelay: boolean;
26
+ /** `room <name>` positional, or `--room <name>`: join/create a named room. */
27
+ readonly room: string | undefined;
26
28
  }
27
29
  /**
28
30
  * Parse argv (the slice AFTER the program name) into a command + options.
package/dist/cli.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-JIBC3OHV.js";
4
+ } from "./chunk-ZD6JBWEW.js";
5
5
  import {
6
6
  CANDIDATES,
7
7
  DEFAULT_HANDLE,
8
8
  LIVE_NOTICE,
9
9
  MAX_HANDLE_LEN,
10
+ ROOM_TOPIC_PREFIX,
10
11
  TOPIC_PREFIX,
11
12
  addBlock,
12
13
  allLeagueNames,
@@ -37,8 +38,9 @@ import {
37
38
  sanitizePeerText,
38
39
  saveHandle,
39
40
  signHelloClaims,
40
- startDiscovery
41
- } from "./chunk-VZXPYU2C.js";
41
+ startDiscovery,
42
+ startRoom
43
+ } from "./chunk-HDHJLZZG.js";
42
44
 
43
45
  // src/cli.ts
44
46
  import readline from "readline";
@@ -912,6 +914,56 @@ var webAppHtml = `<!DOCTYPE html>
912
914
  .incoming-call p{ color: var(--muted); font-size: .85rem; margin: 0 0 18px; }
913
915
  .incoming-call .ic-actions{ display: flex; gap: 10px; }
914
916
 
917
+ /* ---- room full-mesh group video ---- */
918
+ .room-panel{
919
+ position: fixed; right: 18px; bottom: 18px; z-index: 45;
920
+ background: linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
921
+ border: 1px solid var(--border-2); border-radius: 16px; box-shadow: var(--shadow-2);
922
+ padding: 12px 14px; min-width: 240px; max-width: 280px; display: none;
923
+ }
924
+ .room-panel.is-open{ display: block; animation: rise var(--dur-med) var(--ease-out); }
925
+ .room-panel .rp-title{ font-size: .78rem; font-weight: 800; margin-bottom: 6px; display:flex; align-items:center; gap:6px; }
926
+ .room-panel .rp-meta{ font-size: .7rem; color: var(--muted-2); margin-bottom: 10px; line-height: 1.4; word-break: break-all; }
927
+ .room-panel .rp-actions{ display:flex; gap: 8px; }
928
+ .room-panel .rp-btn{
929
+ border: 0; border-radius: 9px; padding: 8px 14px; font-weight: 700; font-size: .78rem;
930
+ background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
931
+ flex: 1;
932
+ }
933
+ .room-panel .rp-btn:disabled{ opacity: .4; cursor: not-allowed; }
934
+ .room-panel .rp-btn.is-leave{ background: var(--danger); color: #2a0a0c; }
935
+ .room-video{
936
+ position: fixed; inset: 0; z-index: 68;
937
+ display: none; flex-direction: column; align-items: stretch; justify-content: flex-start;
938
+ background: rgba(12,7,15,.86); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
939
+ padding: 18px 18px 80px;
940
+ }
941
+ .room-video.is-open{ display: flex; }
942
+ .room-video .rv-head{
943
+ display:flex; align-items:center; justify-content: space-between; gap: 12px;
944
+ margin-bottom: 14px; color: var(--fg); font-weight: 800; font-size: .9rem;
945
+ }
946
+ .room-video .rv-grid{
947
+ flex: 1; display: grid; gap: 12px; min-height: 0;
948
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
949
+ align-content: start;
950
+ }
951
+ .room-video .rv-tile{
952
+ display:flex; flex-direction: column; gap: 6px; min-width: 0;
953
+ }
954
+ .room-video .rv-tile video{
955
+ width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 14px;
956
+ border: 1px solid var(--border-2); object-fit: cover;
957
+ }
958
+ .room-video .rv-tile .rv-label{
959
+ font-size: .75rem; color: var(--muted); font-weight: 600; word-break: break-all;
960
+ }
961
+ .room-video .rv-leave{
962
+ position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
963
+ border: 0; border-radius: 999px; padding: 12px 22px; font-weight: 800; font-size: .9rem;
964
+ background: var(--danger); color: #2a0a0c;
965
+ }
966
+
915
967
  /* ---- live text chat (browser <-> PeerLink over /live/message) ---- */
916
968
  .live-actions{ display: flex; gap: 6px; flex-shrink: 0; }
917
969
  .live-row .cbtn{
@@ -1149,6 +1201,24 @@ var webAppHtml = `<!DOCTYPE html>
1149
1201
  <button class="vhangup" id="hangupBtn" type="button">Hang up</button>
1150
1202
  </div>
1151
1203
 
1204
+ <aside class="room-panel" id="roomPanel" aria-label="Room">
1205
+ <div class="rp-title"><span class="lp-dot" aria-hidden="true"></span> Room</div>
1206
+ <div class="rp-meta" id="roomMeta">not in a room</div>
1207
+ <div class="rp-actions">
1208
+ <button class="rp-btn" id="roomJoinBtn" type="button">Join video</button>
1209
+ <button class="rp-btn is-leave" id="roomLeaveBtn" type="button" disabled>Leave</button>
1210
+ </div>
1211
+ </aside>
1212
+
1213
+ <div class="room-video" id="roomVideo" role="dialog" aria-modal="true" aria-label="Room video">
1214
+ <div class="rv-head">
1215
+ <span id="roomVideoTitle">Room video</span>
1216
+ <span id="roomVideoCount" style="font-weight:600;color:var(--muted);font-size:.78rem"></span>
1217
+ </div>
1218
+ <div class="rv-grid" id="roomVideoGrid"></div>
1219
+ <button class="rv-leave" id="roomVideoLeaveBtn" type="button">Leave video</button>
1220
+ </div>
1221
+
1152
1222
  <script>
1153
1223
  (function(){
1154
1224
  "use strict";
@@ -1814,6 +1884,252 @@ var webAppHtml = `<!DOCTYPE html>
1814
1884
  }
1815
1885
  refreshPeers();
1816
1886
  setInterval(refreshPeers, 4000);
1887
+
1888
+ /* ---- Room full-mesh group video ----------------------------------------
1889
+ * When vibedate open --room <name> is attached, /api/room exposes the
1890
+ * roster. Each browser grabs getUserMedia once and opens one
1891
+ * RTCPeerConnection to every other member. Deterministic offerer election
1892
+ * (self < other lexicographic) avoids glare. Signaling rides the local
1893
+ * server's merged mailbox: GET /room/signal?handle=SELF returns
1894
+ * { frame: { from, rtc } }, POST /room/signal { handle: TARGET, frame }
1895
+ * relays one rtc-* frame to that member. */
1896
+ var roomPanel = document.getElementById("roomPanel");
1897
+ var roomMeta = document.getElementById("roomMeta");
1898
+ var roomJoinBtn = document.getElementById("roomJoinBtn");
1899
+ var roomLeaveBtn = document.getElementById("roomLeaveBtn");
1900
+ var roomVideo = document.getElementById("roomVideo");
1901
+ var roomVideoGrid = document.getElementById("roomVideoGrid");
1902
+ var roomVideoTitle = document.getElementById("roomVideoTitle");
1903
+ var roomVideoCount = document.getElementById("roomVideoCount");
1904
+ var roomVideoLeaveBtn = document.getElementById("roomVideoLeaveBtn");
1905
+
1906
+ var roomVid = { localStream: null, peers: {}, self: null, room: null, joined: false, polling: false };
1907
+ var roomLastRoster = [];
1908
+
1909
+ function roomPostSignal(handle, frame){
1910
+ return fetch("/room/signal", {
1911
+ method: "POST",
1912
+ headers: { "content-type": "application/json" },
1913
+ body: JSON.stringify({ handle: handle, frame: frame })
1914
+ }).catch(function(){ /* best effort */ });
1915
+ }
1916
+
1917
+ function roomFetchSignal(selfHandle, timeoutMs){
1918
+ var ctrl = new AbortController();
1919
+ var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
1920
+ return fetch("/room/signal?handle=" + encodeURIComponent(selfHandle), { signal: ctrl.signal })
1921
+ .then(function(r){ clearTimeout(t); return r; })
1922
+ .catch(function(e){ clearTimeout(t); throw e; });
1923
+ }
1924
+
1925
+ function makeRoomTile(label, muted){
1926
+ var tile = document.createElement("div");
1927
+ tile.className = "rv-tile";
1928
+ var vid = document.createElement("video");
1929
+ vid.autoplay = true;
1930
+ vid.playsInline = true;
1931
+ if (muted) vid.muted = true;
1932
+ var lab = document.createElement("div");
1933
+ lab.className = "rv-label";
1934
+ lab.textContent = label;
1935
+ tile.appendChild(vid);
1936
+ tile.appendChild(lab);
1937
+ roomVideoGrid.appendChild(tile);
1938
+ return vid;
1939
+ }
1940
+
1941
+ function updateRoomCount(){
1942
+ var n = 0;
1943
+ if (roomVid.localStream) n++;
1944
+ for (var k in roomVid.peers) if (Object.prototype.hasOwnProperty.call(roomVid.peers, k)) n++;
1945
+ roomVideoCount.textContent = n ? (n + " tile" + (n === 1 ? "" : "s")) : "";
1946
+ }
1947
+
1948
+ function connectPeer(handle, role){
1949
+ if (!roomVid.localStream || !roomVid.self) return null;
1950
+ if (roomVid.peers[handle]) return roomVid.peers[handle];
1951
+ var pc = new RTCPeerConnection(rtcConfig());
1952
+ var videoEl = makeRoomTile(handle, false);
1953
+ var peer = { pc: pc, videoEl: videoEl, role: role };
1954
+ roomVid.peers[handle] = peer;
1955
+ updateRoomCount();
1956
+
1957
+ roomVid.localStream.getTracks().forEach(function(t){ pc.addTrack(t, roomVid.localStream); });
1958
+ pc.onicecandidate = function(e){
1959
+ // Trickle ICE to this peer. Empty candidate = end-of-candidates marker.
1960
+ roomPostSignal(handle, {
1961
+ t: "rtc-ice",
1962
+ candidate: e.candidate && e.candidate.candidate ? e.candidate.candidate : ""
1963
+ });
1964
+ };
1965
+ pc.ontrack = function(e){
1966
+ if (e.streams && e.streams[0]) videoEl.srcObject = e.streams[0];
1967
+ };
1968
+ pc.onconnectionstatechange = function(){
1969
+ // Quiet \u2014 closed peers stay in the grid until Leave clears everything.
1970
+ };
1971
+
1972
+ if (role === "offerer") {
1973
+ (async function(){
1974
+ try {
1975
+ var offer = await pc.createOffer();
1976
+ await pc.setLocalDescription(offer);
1977
+ await roomPostSignal(handle, { t: "rtc-offer", sdp: offer.sdp });
1978
+ } catch(e){ /* offer failed \u2014 peer will retry on next roster refresh if still present */ }
1979
+ })();
1980
+ }
1981
+ return peer;
1982
+ }
1983
+
1984
+ async function handleRoomSignal(from, rtc){
1985
+ if (!rtc || !from || from === roomVid.self) return;
1986
+ if (rtc.t === "rtc-offer") {
1987
+ var peer = roomVid.peers[from];
1988
+ if (!peer) peer = connectPeer(from, "answerer");
1989
+ if (!peer) return;
1990
+ try {
1991
+ await peer.pc.setRemoteDescription({ type: "offer", sdp: rtc.sdp });
1992
+ var answer = await peer.pc.createAnswer();
1993
+ await peer.pc.setLocalDescription(answer);
1994
+ await roomPostSignal(from, { t: "rtc-answer", sdp: answer.sdp });
1995
+ } catch(e){}
1996
+ return;
1997
+ }
1998
+ if (rtc.t === "rtc-answer") {
1999
+ var pAns = roomVid.peers[from];
2000
+ if (!pAns) return;
2001
+ try { await pAns.pc.setRemoteDescription({ type: "answer", sdp: rtc.sdp }); } catch(e){}
2002
+ return;
2003
+ }
2004
+ if (rtc.t === "rtc-ice") {
2005
+ var pIce = roomVid.peers[from];
2006
+ if (!pIce) return;
2007
+ // Skip empty candidate (end-of-candidates) \u2014 browsers tolerate either way.
2008
+ if (!rtc.candidate) return;
2009
+ try { await pIce.pc.addIceCandidate({ candidate: rtc.candidate }); } catch(e){}
2010
+ }
2011
+ }
2012
+
2013
+ async function roomSignalLoop(){
2014
+ if (roomVid.polling) return;
2015
+ roomVid.polling = true;
2016
+ while (roomVid.joined && roomVid.self) {
2017
+ var res;
2018
+ try { res = await roomFetchSignal(roomVid.self, 5000); }
2019
+ catch(e){ await sleep(400); continue; }
2020
+ if (!res || res.status !== 200) { await sleep(400); continue; }
2021
+ var data;
2022
+ try { data = await res.json(); } catch(e){ continue; }
2023
+ var f = data && data.frame;
2024
+ if (!f) continue; // timeout empty
2025
+ // Sender-routed shape: { from, rtc } (or a nested frame.frame fallback).
2026
+ var from = f.from;
2027
+ var rtc = f.rtc || f.frame || f;
2028
+ if (from && rtc && rtc.t) await handleRoomSignal(from, rtc);
2029
+ }
2030
+ roomVid.polling = false;
2031
+ }
2032
+
2033
+ function meshWithMembers(members){
2034
+ if (!roomVid.joined || !roomVid.self || !roomVid.localStream) return;
2035
+ var self = roomVid.self;
2036
+ var seen = {};
2037
+ (members || []).forEach(function(m){
2038
+ var h = m && m.handle;
2039
+ if (!h || h === self) return;
2040
+ seen[h] = true;
2041
+ if (roomVid.peers[h]) return;
2042
+ // Deterministic glare avoidance: lower handle is the offerer.
2043
+ if (self < h) connectPeer(h, "offerer");
2044
+ // else wait for their offer via the long-poll loop
2045
+ });
2046
+ }
2047
+
2048
+ async function joinRoomVideo(){
2049
+ if (roomVid.joined) return;
2050
+ if (!roomVid.self || !roomVid.room) return;
2051
+ roomJoinBtn.disabled = true;
2052
+ try {
2053
+ roomVid.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
2054
+ } catch(e){
2055
+ roomJoinBtn.disabled = false;
2056
+ roomMeta.textContent = (roomVid.room || "room") + " \xB7 camera/mic blocked";
2057
+ return;
2058
+ }
2059
+ roomVid.joined = true;
2060
+ roomVid.peers = {};
2061
+ roomVideoGrid.innerHTML = "";
2062
+ var localEl = makeRoomTile((roomVid.self || "you") + " (you)", true);
2063
+ localEl.srcObject = roomVid.localStream;
2064
+ roomVideo.classList.add("is-open");
2065
+ roomVideoTitle.textContent = "Room \xB7 " + roomVid.room;
2066
+ roomLeaveBtn.disabled = false;
2067
+ updateRoomCount();
2068
+ roomSignalLoop();
2069
+ // Seed mesh against the latest roster, then refresh once more for races.
2070
+ meshWithMembers(roomLastRoster);
2071
+ try {
2072
+ var res = await fetch("/api/room");
2073
+ if (res.status === 200) {
2074
+ var data = await res.json();
2075
+ var members = (data && data.members) || [];
2076
+ roomLastRoster = members;
2077
+ meshWithMembers(members);
2078
+ }
2079
+ } catch(e){}
2080
+ }
2081
+
2082
+ function leaveRoomVideo(){
2083
+ roomVid.joined = false;
2084
+ for (var h in roomVid.peers) {
2085
+ if (!Object.prototype.hasOwnProperty.call(roomVid.peers, h)) continue;
2086
+ try { roomVid.peers[h].pc.close(); } catch(e){}
2087
+ }
2088
+ roomVid.peers = {};
2089
+ if (roomVid.localStream) {
2090
+ roomVid.localStream.getTracks().forEach(function(t){ try { t.stop(); } catch(e){} });
2091
+ roomVid.localStream = null;
2092
+ }
2093
+ roomVideoGrid.innerHTML = "";
2094
+ roomVideo.classList.remove("is-open");
2095
+ roomLeaveBtn.disabled = true;
2096
+ roomJoinBtn.disabled = !roomVid.room;
2097
+ updateRoomCount();
2098
+ }
2099
+
2100
+ roomJoinBtn.addEventListener("click", function(){ joinRoomVideo().catch(function(){ leaveRoomVideo(); }); });
2101
+ roomLeaveBtn.addEventListener("click", leaveRoomVideo);
2102
+ roomVideoLeaveBtn.addEventListener("click", leaveRoomVideo);
2103
+
2104
+ async function refreshRoom(){
2105
+ try {
2106
+ var res = await fetch("/api/room");
2107
+ if (res.status !== 200) return;
2108
+ var data = await res.json();
2109
+ var roomName = data && data.room;
2110
+ if (!roomName) {
2111
+ if (roomVid.joined) leaveRoomVideo();
2112
+ roomVid.room = null;
2113
+ roomVid.self = null;
2114
+ roomLastRoster = [];
2115
+ roomPanel.classList.remove("is-open");
2116
+ return;
2117
+ }
2118
+ roomVid.room = roomName;
2119
+ roomVid.self = data.self || null;
2120
+ roomLastRoster = (data.members) || [];
2121
+ var n = roomLastRoster.length;
2122
+ roomMeta.textContent = roomName +
2123
+ (roomVid.self ? " \xB7 you " + roomVid.self : "") +
2124
+ " \xB7 " + n + " other" + (n === 1 ? "" : "s");
2125
+ roomPanel.classList.add("is-open");
2126
+ roomJoinBtn.disabled = roomVid.joined || !roomVid.self;
2127
+ roomLeaveBtn.disabled = !roomVid.joined;
2128
+ if (roomVid.joined) meshWithMembers(roomLastRoster);
2129
+ } catch(e){}
2130
+ }
2131
+ refreshRoom();
2132
+ setInterval(refreshRoom, 4000);
1817
2133
  })();
1818
2134
  </script>
1819
2135
  </body>
@@ -1891,6 +2207,93 @@ function createLiveBridge() {
1891
2207
  };
1892
2208
  return bridge;
1893
2209
  }
2210
+ var MAX_QUEUED_ROOM_SIGNALS = 200;
2211
+ function createRoomBridge(name) {
2212
+ let session;
2213
+ const messageQueue = [];
2214
+ const messageWaiters = [];
2215
+ const signalQueue = [];
2216
+ const signalWaiters = [];
2217
+ const drainMessage = (m) => {
2218
+ const safe = { ...m, text: sanitizePeerText(m.text) };
2219
+ const waiter = messageWaiters.shift();
2220
+ if (waiter !== void 0) {
2221
+ waiter(safe);
2222
+ return;
2223
+ }
2224
+ messageQueue.push(safe);
2225
+ if (messageQueue.length > MAX_QUEUED_MESSAGES) {
2226
+ messageQueue.splice(0, messageQueue.length - MAX_QUEUED_MESSAGES);
2227
+ }
2228
+ };
2229
+ const drainSignal = (from, frame) => {
2230
+ const tagged = { from, rtc: frame };
2231
+ const waiter = signalWaiters.shift();
2232
+ if (waiter !== void 0) {
2233
+ waiter(tagged);
2234
+ return;
2235
+ }
2236
+ signalQueue.push(tagged);
2237
+ if (signalQueue.length > MAX_QUEUED_ROOM_SIGNALS) {
2238
+ signalQueue.splice(0, signalQueue.length - MAX_QUEUED_ROOM_SIGNALS);
2239
+ }
2240
+ };
2241
+ return {
2242
+ name,
2243
+ get self() {
2244
+ return session?.hello.handle;
2245
+ },
2246
+ get members() {
2247
+ if (session === void 0) return [];
2248
+ return [...session.members.values()].map((h) => ({
2249
+ handle: h.handle,
2250
+ league: h.league,
2251
+ harness: h.harness,
2252
+ ...h.verified !== void 0 ? { verified: h.verified } : {},
2253
+ ...h.identityVerified !== void 0 ? { identityVerified: h.identityVerified } : {}
2254
+ }));
2255
+ },
2256
+ attach(s) {
2257
+ session = s;
2258
+ s.onMessage(drainMessage);
2259
+ s.onSignal((from, frame) => drainSignal(from, frame));
2260
+ },
2261
+ broadcast(text) {
2262
+ session?.broadcast(text);
2263
+ },
2264
+ async pollMessage(timeoutMs) {
2265
+ if (messageQueue.length > 0) return messageQueue.shift() ?? null;
2266
+ return new Promise((resolve) => {
2267
+ const timer = setTimeout(() => {
2268
+ const idx = messageWaiters.indexOf(resolve);
2269
+ if (idx >= 0) messageWaiters.splice(idx, 1);
2270
+ resolve(null);
2271
+ }, timeoutMs);
2272
+ messageWaiters.push((m) => {
2273
+ clearTimeout(timer);
2274
+ resolve(m);
2275
+ });
2276
+ });
2277
+ },
2278
+ sendSignal(handle2, frame) {
2279
+ session?.sendSignal(handle2, frame);
2280
+ },
2281
+ async pollSignal(_handle, timeoutMs) {
2282
+ if (signalQueue.length > 0) return signalQueue.shift() ?? null;
2283
+ return new Promise((resolve) => {
2284
+ const timer = setTimeout(() => {
2285
+ const idx = signalWaiters.indexOf(resolve);
2286
+ if (idx >= 0) signalWaiters.splice(idx, 1);
2287
+ resolve(null);
2288
+ }, timeoutMs);
2289
+ signalWaiters.push((s) => {
2290
+ clearTimeout(timer);
2291
+ resolve(s);
2292
+ });
2293
+ });
2294
+ }
2295
+ };
2296
+ }
1894
2297
  function currentState(dir) {
1895
2298
  const p = loadProfile(dir);
1896
2299
  if (!p) return { connected: false, candidates: [] };
@@ -2111,11 +2514,101 @@ async function handle(req, res, opts) {
2111
2514
  sendJson(res, 200, { ok: true });
2112
2515
  return;
2113
2516
  }
2517
+ if (req.method === "GET" && pathname === "/api/room") {
2518
+ const room = opts.room;
2519
+ sendJson(res, 200, room ? { room: room.name, self: room.self ?? null, members: room.members } : { room: null, self: null, members: [] });
2520
+ return;
2521
+ }
2522
+ if (req.method === "GET" && pathname === "/room/message") {
2523
+ const room = opts.room;
2524
+ if (!room) {
2525
+ sendJson(res, 200, { message: null, reason: "room-not-attached" });
2526
+ return;
2527
+ }
2528
+ const message = await room.pollMessage(25e3);
2529
+ if (req.destroyed || res.writableEnded) return;
2530
+ sendJson(res, 200, { message });
2531
+ return;
2532
+ }
2533
+ if (req.method === "POST" && pathname === "/room/message") {
2534
+ const room = opts.room;
2535
+ if (!room) {
2536
+ sendJson(res, 400, { error: "room-not-attached" });
2537
+ return;
2538
+ }
2539
+ const body = await readBody(req);
2540
+ let parsed = {};
2541
+ try {
2542
+ parsed = JSON.parse(body);
2543
+ } catch {
2544
+ sendJson(res, 400, { error: "invalid JSON body" });
2545
+ return;
2546
+ }
2547
+ const text = parsed["text"];
2548
+ if (typeof text !== "string") {
2549
+ sendJson(res, 400, { error: "missing text" });
2550
+ return;
2551
+ }
2552
+ const reParsed = parseFrame(
2553
+ JSON.stringify({ t: "msg", id: randomUUID(), text, at: Date.now() })
2554
+ );
2555
+ if (reParsed === null || reParsed.t !== "msg") {
2556
+ sendJson(res, 400, { error: "invalid message text" });
2557
+ return;
2558
+ }
2559
+ room.broadcast(reParsed.text);
2560
+ sendJson(res, 200, { ok: true });
2561
+ return;
2562
+ }
2563
+ if (req.method === "GET" && pathname === "/room/signal") {
2564
+ const room = opts.room;
2565
+ if (!room) {
2566
+ sendJson(res, 200, { frame: null, reason: "room-not-attached" });
2567
+ return;
2568
+ }
2569
+ const handle2 = url.searchParams.get("handle") ?? "";
2570
+ if (handle2 === "") {
2571
+ sendJson(res, 400, { error: "missing handle" });
2572
+ return;
2573
+ }
2574
+ const frame = await room.pollSignal(handle2, 25e3);
2575
+ if (req.destroyed || res.writableEnded) return;
2576
+ sendJson(res, 200, { frame });
2577
+ return;
2578
+ }
2579
+ if (req.method === "POST" && pathname === "/room/signal") {
2580
+ const room = opts.room;
2581
+ if (!room) {
2582
+ sendJson(res, 400, { error: "room-not-attached" });
2583
+ return;
2584
+ }
2585
+ const body = await readBody(req);
2586
+ let parsed = {};
2587
+ try {
2588
+ parsed = JSON.parse(body);
2589
+ } catch {
2590
+ sendJson(res, 400, { error: "invalid JSON body" });
2591
+ return;
2592
+ }
2593
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
2594
+ if (handle2 === "") {
2595
+ sendJson(res, 400, { error: "missing handle" });
2596
+ return;
2597
+ }
2598
+ const reParsed = parseFrame(JSON.stringify(parsed["frame"]));
2599
+ if (reParsed === null || reParsed.t !== "rtc-offer" && reParsed.t !== "rtc-answer" && reParsed.t !== "rtc-ice") {
2600
+ sendJson(res, 400, { error: "invalid rtc frame" });
2601
+ return;
2602
+ }
2603
+ room.sendSignal(handle2, reParsed);
2604
+ sendJson(res, 200, { ok: true });
2605
+ return;
2606
+ }
2114
2607
  sendJson(res, 404, { error: "not found" });
2115
2608
  }
2116
2609
 
2117
2610
  // src/cli.ts
2118
- var VERSION = "0.6.0";
2611
+ var VERSION = "0.7.1";
2119
2612
  function parsePort(raw) {
2120
2613
  const n = Number(raw);
2121
2614
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -2131,7 +2624,8 @@ function parseArgs(argv) {
2131
2624
  arg: void 0,
2132
2625
  to: void 0,
2133
2626
  keepAlive: false,
2134
- viaRelay: false
2627
+ viaRelay: false,
2628
+ room: void 0
2135
2629
  };
2136
2630
  for (let i = 0; i < argv.length; i++) {
2137
2631
  const a = argv[i];
@@ -2145,7 +2639,8 @@ function parseArgs(argv) {
2145
2639
  arg: void 0,
2146
2640
  to: void 0,
2147
2641
  keepAlive: false,
2148
- viaRelay: false
2642
+ viaRelay: false,
2643
+ room: void 0
2149
2644
  };
2150
2645
  }
2151
2646
  if (a === "--help" || a === "-h") {
@@ -2158,7 +2653,8 @@ function parseArgs(argv) {
2158
2653
  arg: void 0,
2159
2654
  to: void 0,
2160
2655
  keepAlive: false,
2161
- viaRelay: false
2656
+ viaRelay: false,
2657
+ room: void 0
2162
2658
  };
2163
2659
  }
2164
2660
  if (a === "--live") {
@@ -2173,6 +2669,18 @@ function parseArgs(argv) {
2173
2669
  out = { ...out, viaRelay: true };
2174
2670
  continue;
2175
2671
  }
2672
+ if (a === "--room") {
2673
+ const next = argv[i + 1];
2674
+ if (next !== void 0) {
2675
+ out = { ...out, room: next };
2676
+ i++;
2677
+ }
2678
+ continue;
2679
+ }
2680
+ if (a.startsWith("--room=")) {
2681
+ out = { ...out, room: a.slice("--room=".length) };
2682
+ continue;
2683
+ }
2176
2684
  if (a === "--any") {
2177
2685
  out = { ...out, any: true };
2178
2686
  continue;
@@ -2208,7 +2716,7 @@ function parseArgs(argv) {
2208
2716
  continue;
2209
2717
  }
2210
2718
  if (a.startsWith("-")) continue;
2211
- const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "daemon" || a === "mcp" || a === "help" ? a : null;
2719
+ const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "daemon" || a === "mcp" || a === "room" || a === "help" ? a : null;
2212
2720
  if (known !== null && out.command === null) {
2213
2721
  out = { ...out, command: known };
2214
2722
  } else if (out.arg === void 0) {
@@ -2452,36 +2960,59 @@ async function cmdDiscover(live, any, viaRelay) {
2452
2960
  );
2453
2961
  return 0;
2454
2962
  }
2455
- async function cmdOpen(port, any) {
2963
+ async function cmdOpen(port, any, room) {
2456
2964
  const profile = loadProfile();
2457
2965
  let live;
2966
+ let roomBridge;
2458
2967
  let session;
2968
+ let roomSession;
2459
2969
  if (profile) {
2460
2970
  if (!canShareLive()) grantLiveConsent();
2461
- live = createLiveBridge();
2971
+ if (room !== void 0) {
2972
+ roomBridge = createRoomBridge(room);
2973
+ } else {
2974
+ live = createLiveBridge();
2975
+ }
2462
2976
  }
2463
- const started = await startServer({ port, live });
2464
- if (profile && live) {
2977
+ const started = await startServer({
2978
+ port,
2979
+ live,
2980
+ ...roomBridge === void 0 ? {} : { room: roomBridge }
2981
+ });
2982
+ if (profile) {
2465
2983
  process2.stdout.write(`
2466
2984
  ${LIVE_NOTICE}
2467
2985
  `);
2468
2986
  const hello = buildHello(profile);
2469
- const { topics, acceptLeague } = discoveryScope(profile.league, any);
2470
- void startDiscovery({
2471
- hello,
2472
- topics,
2473
- acceptLeague,
2474
- isBlocked: blockedChecker(),
2475
- onLink: (link) => live.addLink(link)
2476
- }).then((s) => {
2477
- session = s;
2478
- }).catch(() => {
2479
- });
2987
+ if (room !== void 0 && roomBridge !== void 0) {
2988
+ void startRoom({ hello, room, isBlocked: blockedChecker() }).then((s) => {
2989
+ roomSession = s;
2990
+ roomBridge.attach(s);
2991
+ }).catch(() => {
2992
+ });
2993
+ } else if (live) {
2994
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
2995
+ void startDiscovery({
2996
+ hello,
2997
+ topics,
2998
+ acceptLeague,
2999
+ isBlocked: blockedChecker(),
3000
+ onLink: (link) => live.addLink(link)
3001
+ }).then((s) => {
3002
+ session = s;
3003
+ }).catch(() => {
3004
+ });
3005
+ }
2480
3006
  }
2481
3007
  process2.stdout.write(`
2482
3008
  vibedating local web app \u2192 ${started.url}
2483
3009
  `);
2484
- if (live) {
3010
+ if (room !== void 0) {
3011
+ process2.stdout.write(
3012
+ ` \u2022 room: ${room} \u2014 roster + group chat + full-mesh group video (~6 people; an SFU is the upgrade path for bigger rooms)
3013
+ `
3014
+ );
3015
+ } else if (live) {
2485
3016
  process2.stdout.write(
2486
3017
  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"
2487
3018
  );
@@ -2495,6 +3026,7 @@ async function cmdOpen(port, any) {
2495
3026
  process2.once("SIGTERM", () => resolve());
2496
3027
  });
2497
3028
  process2.stdout.write("\n shutting down\u2026\n");
3029
+ if (roomSession) await roomSession.close();
2498
3030
  if (session) await session.close();
2499
3031
  await new Promise((resolve) => started.server.close(() => resolve()));
2500
3032
  return 0;
@@ -2717,6 +3249,104 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2717
3249
  process2.stdout.write("\n");
2718
3250
  return 0;
2719
3251
  }
3252
+ async function cmdRoom(name, keepAlive) {
3253
+ if (name === void 0 || name.trim() === "") {
3254
+ process2.stderr.write("usage: vibedating room <name>\n");
3255
+ return 1;
3256
+ }
3257
+ const profile = loadProfile();
3258
+ if (!profile) {
3259
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
3260
+ return 1;
3261
+ }
3262
+ if (!canShareLive()) grantLiveConsent();
3263
+ if (!canShareLive()) {
3264
+ process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
3265
+ return 1;
3266
+ }
3267
+ const hello = buildHello(profile);
3268
+ process2.stdout.write("\n");
3269
+ process2.stdout.write(` ${LIVE_NOTICE}
3270
+ `);
3271
+ process2.stdout.write(` ${MARKS_LEGEND}
3272
+ `);
3273
+ process2.stdout.write(` room: ${name} (cross-league \u2014 everyone in the room is a member)
3274
+ `);
3275
+ const session = await startRoom({
3276
+ hello,
3277
+ room: name,
3278
+ isBlocked: blockedChecker()
3279
+ });
3280
+ session.onRoster((members) => {
3281
+ if (members.length === 0) {
3282
+ process2.stdout.write(" \xB7 room empty \u2014 waiting for members\u2026\n");
3283
+ return;
3284
+ }
3285
+ const list = members.map(
3286
+ (m) => `${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}`
3287
+ ).join(", ");
3288
+ process2.stdout.write(` \xB7 room (${members.length}): ${list}
3289
+ `);
3290
+ });
3291
+ session.onMessage((m) => {
3292
+ process2.stdout.write(` <${sanitizePeerText(m.from)}> ${sanitizePeerText(m.text)}
3293
+ `);
3294
+ });
3295
+ process2.stdout.write(
3296
+ ` topic: ${ROOM_TOPIC_PREFIX}${name} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
3297
+ `
3298
+ );
3299
+ process2.stdout.write(" type to broadcast to the whole room \xB7 /who \xB7 /quit\n");
3300
+ process2.stdout.write(` group video: run \`vibedating open --room ${name}\`
3301
+
3302
+ `);
3303
+ const rl = readline.createInterface({ input: process2.stdin, terminal: false });
3304
+ const stop = () => {
3305
+ rl.close();
3306
+ };
3307
+ process2.once("SIGINT", stop);
3308
+ process2.once("SIGTERM", stop);
3309
+ let quitRequested = false;
3310
+ for await (const line of rl) {
3311
+ const text = line.trim();
3312
+ if (text === "/quit") {
3313
+ quitRequested = true;
3314
+ break;
3315
+ }
3316
+ if (text === "/who") {
3317
+ const members = [...session.members.values()];
3318
+ if (members.length === 0) {
3319
+ process2.stdout.write(" \xB7 room empty\n");
3320
+ } else {
3321
+ for (const m of members) {
3322
+ process2.stdout.write(
3323
+ ` \xB7 ${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}
3324
+ `
3325
+ );
3326
+ }
3327
+ }
3328
+ continue;
3329
+ }
3330
+ if (text === "") continue;
3331
+ const reached = session.broadcast(text);
3332
+ if (reached.length === 0) {
3333
+ process2.stdout.write(" \xB7 room empty \u2014 no one to hear you yet\n");
3334
+ }
3335
+ }
3336
+ if (!quitRequested && shouldKeepAlive(keepAlive, process2.stdin.isTTY)) {
3337
+ process2.stdout.write(" stdin closed \u2014 staying in the room (Ctrl+C / SIGTERM to leave)\n");
3338
+ await new Promise((resolve) => {
3339
+ process2.once("SIGINT", () => resolve());
3340
+ process2.once("SIGTERM", () => resolve());
3341
+ });
3342
+ }
3343
+ process2.removeListener("SIGINT", stop);
3344
+ process2.removeListener("SIGTERM", stop);
3345
+ process2.stdout.write("\n leaving the room\u2026\n");
3346
+ await session.close();
3347
+ process2.stdout.write("\n");
3348
+ return 0;
3349
+ }
2720
3350
  async function cmdFind(targetArg, any) {
2721
3351
  if (targetArg === void 0 || targetArg.trim() === "") {
2722
3352
  process2.stderr.write("usage: vibedating find <@handle> [--any]\n");
@@ -2968,9 +3598,15 @@ Usage:
2968
3598
  NEW matches, never opens chat/video. install adds a
2969
3599
  login service (launchd on macOS, systemd on Linux);
2970
3600
  uninstall removes it.
2971
- vibedating open [--port N] [--any] Serve the local web app (default: random port)
2972
- + live video + chat with connected peers
2973
- (your league + adjacent; --any = every league)
3601
+ vibedating room <name> Join/create a named room (multi-peer): live roster
3602
+ + group text chat broadcast to all members.
3603
+ Consent-gated like live. /who lists members, /quit leaves.
3604
+ vibedating open [--port N] [--any] [--room <name>] Serve the local web app (default:
3605
+ random port) + live video + chat with connected peers
3606
+ (your league + adjacent; --any = every league).
3607
+ --room <name> opens the room view instead: roster +
3608
+ group chat + full-mesh group video (~6 people; an SFU
3609
+ is the upgrade path for bigger rooms).
2974
3610
  vibedating mcp Run the stdio MCP server (profile, matches)
2975
3611
  vibedating --version
2976
3612
  vibedating --help
@@ -3022,11 +3658,13 @@ async function main(argv) {
3022
3658
  case "discover":
3023
3659
  return cmdDiscover(parsed.live, parsed.any, parsed.viaRelay);
3024
3660
  case "open":
3025
- return cmdOpen(parsed.port, parsed.any);
3661
+ return cmdOpen(parsed.port, parsed.any, parsed.room);
3026
3662
  case "live":
3027
3663
  return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive, parsed.viaRelay);
3028
3664
  case "find":
3029
3665
  return cmdFind(parsed.arg, parsed.any);
3666
+ case "room":
3667
+ return cmdRoom(parsed.arg ?? parsed.room, parsed.keepAlive);
3030
3668
  case "mcp":
3031
3669
  await runMcp();
3032
3670
  return 0;
package/dist/index.d.ts CHANGED
@@ -351,6 +351,87 @@ declare function createNostrRelayLink(opts: CreateNostrRelayLinkOptions): Promis
351
351
  */
352
352
  declare function createNostrPoolTransport(urls?: readonly string[]): Promise<RelayTransport>;
353
353
 
354
+ /** Namespace prefix so room topics never collide with league topics (or anything
355
+ * else on the DHT). Mirrors {@link p2p.TOPIC_PREFIX} for the 1:1 path. */
356
+ declare const ROOM_TOPIC_PREFIX = "vibedate-room:";
357
+ /**
358
+ * Derive the 32-byte DHT topic for a named room. Deterministic: everyone who
359
+ * joins the same room name anywhere in the world hashes to the same topic,
360
+ * which is the entire discovery mechanism. Pure (mirrors {@link p2p.leagueTopic}).
361
+ */
362
+ declare function roomTopic(name: string): Buffer;
363
+ /** A room member = a connected, handshaken peer. Same shape as a live peer. */
364
+ type RoomMember = PeerHello;
365
+ /** One group chat message: a `msg` frame's payload tagged with the sender. */
366
+ interface RoomMessage {
367
+ /** Sender's handle (from the validated hello — UNTRUSTED display data). */
368
+ readonly from: string;
369
+ readonly id: string;
370
+ readonly text: string;
371
+ readonly at: number;
372
+ }
373
+ interface RoomOptions {
374
+ /** What we broadcast. Must already be consent-gated by the caller. */
375
+ readonly hello: PeerHello;
376
+ /** Room name → its own DHT topic (see {@link roomTopic}). */
377
+ readonly room: string;
378
+ /** DHT bootstrap nodes; omit for the public DHT. Tests pass a local testnet. */
379
+ readonly bootstrap?: ReadonlyArray<{
380
+ readonly host: string;
381
+ readonly port: number;
382
+ }>;
383
+ /** Where peers.json lives. Defaults to ~/.vibedating. */
384
+ readonly stateDir?: string;
385
+ /** Predicate over a blocked handle — a blocked peer's hello is dropped exactly
386
+ * like in 1:1 discovery. Default: nothing blocked. */
387
+ readonly isBlocked?: (handle: string) => boolean;
388
+ /** Match-notification sink (tests capture with a fake). Best-effort. */
389
+ readonly notify?: NotifySink;
390
+ }
391
+ interface RoomSession {
392
+ /** The room name. */
393
+ readonly room: string;
394
+ /** The joined DHT topic (see {@link roomTopic}). */
395
+ readonly topic: Buffer;
396
+ /** What we broadcast. */
397
+ readonly hello: PeerHello;
398
+ /** Live member set, keyed by handle (excludes self). Mirrors the live view
399
+ * semantics of {@link p2p.DiscoverySession.peers}. */
400
+ readonly members: ReadonlyMap<string, RoomMember>;
401
+ /** Resolves when the first DHT announce/lookup round completes. */
402
+ readonly ready: Promise<unknown>;
403
+ /**
404
+ * Broadcast a text message to ALL room members (fan-out: one `msg` frame per
405
+ * member's {@link PeerLink}). Returns the handles the message was sent to.
406
+ * Best-effort: a member whose link has just closed is skipped silently.
407
+ */
408
+ broadcast(text: string): readonly string[];
409
+ /** Register a callback fired for each incoming group message (with sender). */
410
+ onMessage(cb: (m: RoomMessage) => void): void;
411
+ /** Register a callback fired whenever the member roster changes (join/leave). */
412
+ onRoster(cb: (members: readonly RoomMember[]) => void): void;
413
+ /** Register a callback fired for each incoming `rtc-*` signaling frame,
414
+ * tagged with the sender's handle (full-mesh video signaling). */
415
+ onSignal(cb: (from: string, frame: RtcFrame) => void): void;
416
+ /** Relay one `rtc-*` signaling frame to one member (by handle). */
417
+ sendSignal(handle: string, frame: RtcFrame): void;
418
+ /** The underlying {@link PeerLink} to a member (by handle), or undefined. */
419
+ linkFor(handle: string): PeerLink | undefined;
420
+ /** Leave the room and destroy the node. Idempotent. */
421
+ close(): Promise<void>;
422
+ }
423
+ /**
424
+ * Join (or create) a named room on the DHT and discover ALL members. CONSENT
425
+ * GATE LIVES WITH THE CALLER — never call this without the `share:live` grant
426
+ * (or an explicit opt-in in the same breath), exactly like
427
+ * {@link p2p.startDiscovery}.
428
+ *
429
+ * Resolves once the first DHT announce/lookup round completes; the returned
430
+ * session's {@link RoomSession.members} map + {@link RoomSession.onRoster}
431
+ * callback track every member that joins or leaves thereafter.
432
+ */
433
+ declare function startRoom(opts: RoomOptions): Promise<RoomSession>;
434
+
354
435
  /**
355
436
  * Persistent ed25519 identity — binds a handle to a keypair so a peer cannot
356
437
  * impersonate it.
@@ -558,4 +639,4 @@ declare const CANDIDATES: readonly Candidate[];
558
639
  */
559
640
  declare function matches(myLeague: string, candidates?: readonly Candidate[]): Candidate[];
560
641
 
561
- export { BELOW_LEAGUE, CANDIDATES, type Candidate, type CreateNostrRelayLinkOptions, DEFAULT_RELAYS, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type NostrKey, type NostrRelayLink, type PeerHello, type RelayEvent, type RelayFilter, type RelayTransport, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, VIBEDATE_MESSAGE_KIND, VIBEDATE_PRESENCE_KIND, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, conversationTag, createNostrPoolTransport, createNostrRelayLink, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadOrCreateNostrKey, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, serializeHandshake, signHelloClaims, startDiscovery, verifyHelloClaims };
642
+ export { BELOW_LEAGUE, CANDIDATES, type Candidate, type CreateNostrRelayLinkOptions, DEFAULT_RELAYS, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, type HelloClaims, type Identity, type IdentityProof, type IdentityVerdict, LEAGUES, LIVE_NOTICE, type League, type LocalUsageSnapshot, type NostrKey, type NostrRelayLink, type PeerHello, ROOM_TOPIC_PREFIX, type RelayEvent, type RelayFilter, type RelayTransport, type RoomMember, type RoomMessage, type RoomOptions, type RoomSession, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, VIBEDATE_MESSAGE_KIND, VIBEDATE_PRESENCE_KIND, allLeagueNames, canonicalHelloClaims, classifyHelloIdentity, conversationTag, createNostrPoolTransport, createNostrRelayLink, league, leagueIndex, leagueTopic, leaguesWithin, loadOrCreateIdentity, loadOrCreateNostrKey, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, recordPeerMessage, roomTopic, serializeHandshake, signHelloClaims, startDiscovery, startRoom, verifyHelloClaims };
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  DEMO_TOTAL_TOKENS,
6
6
  LEAGUES,
7
7
  LIVE_NOTICE,
8
+ ROOM_TOPIC_PREFIX,
8
9
  TOKENS_ENV,
9
10
  TOPIC_PREFIX,
10
11
  VIBEDATE_MESSAGE_KIND,
@@ -29,11 +30,13 @@ import {
29
30
  readUsage,
30
31
  recordPeer,
31
32
  recordPeerMessage,
33
+ roomTopic,
32
34
  serializeHandshake,
33
35
  signHelloClaims,
34
36
  startDiscovery,
37
+ startRoom,
35
38
  verifyHelloClaims
36
- } from "./chunk-VZXPYU2C.js";
39
+ } from "./chunk-HDHJLZZG.js";
37
40
  export {
38
41
  BELOW_LEAGUE,
39
42
  CANDIDATES,
@@ -41,6 +44,7 @@ export {
41
44
  DEMO_TOTAL_TOKENS,
42
45
  LEAGUES,
43
46
  LIVE_NOTICE,
47
+ ROOM_TOPIC_PREFIX,
44
48
  TOKENS_ENV,
45
49
  TOPIC_PREFIX,
46
50
  VIBEDATE_MESSAGE_KIND,
@@ -65,8 +69,10 @@ export {
65
69
  readUsage,
66
70
  recordPeer,
67
71
  recordPeerMessage,
72
+ roomTopic,
68
73
  serializeHandshake,
69
74
  signHelloClaims,
70
75
  startDiscovery,
76
+ startRoom,
71
77
  verifyHelloClaims
72
78
  };
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  runMcp
3
- } from "./chunk-JIBC3OHV.js";
4
- import "./chunk-VZXPYU2C.js";
3
+ } from "./chunk-ZD6JBWEW.js";
4
+ import "./chunk-HDHJLZZG.js";
5
5
  export {
6
6
  runMcp
7
7
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibedate",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Dating by tokens — matched by usage league across agentic CLIs. Raw usage stays local; only your league is shared. CLI + local web app + MCP. Local-first.",
5
5
  "type": "module",
6
6
  "license": "MIT",