vibedate 0.6.0 → 0.7.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.
@@ -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";
@@ -1891,6 +1893,111 @@ function createLiveBridge() {
1891
1893
  };
1892
1894
  return bridge;
1893
1895
  }
1896
+ var MAX_QUEUED_ROOM_SIGNALS = 200;
1897
+ function createRoomBridge(name) {
1898
+ let session;
1899
+ const messageQueue = [];
1900
+ const messageWaiters = [];
1901
+ const signalBoxes = /* @__PURE__ */ new Map();
1902
+ const signalWaiters = /* @__PURE__ */ new Map();
1903
+ const drainMessage = (m) => {
1904
+ const safe = { ...m, text: sanitizePeerText(m.text) };
1905
+ const waiter = messageWaiters.shift();
1906
+ if (waiter !== void 0) {
1907
+ waiter(safe);
1908
+ return;
1909
+ }
1910
+ messageQueue.push(safe);
1911
+ if (messageQueue.length > MAX_QUEUED_MESSAGES) {
1912
+ messageQueue.splice(0, messageQueue.length - MAX_QUEUED_MESSAGES);
1913
+ }
1914
+ };
1915
+ const drainSignal = (from, frame) => {
1916
+ const waiters = signalWaiters.get(from);
1917
+ if (waiters !== void 0 && waiters.length > 0) {
1918
+ const w = waiters.shift();
1919
+ if (w !== void 0) {
1920
+ if (waiters.length === 0) signalWaiters.delete(from);
1921
+ w(frame);
1922
+ return;
1923
+ }
1924
+ }
1925
+ let box = signalBoxes.get(from);
1926
+ if (box === void 0) {
1927
+ box = [];
1928
+ signalBoxes.set(from, box);
1929
+ }
1930
+ box.push(frame);
1931
+ if (box.length > MAX_QUEUED_ROOM_SIGNALS) {
1932
+ box.splice(0, box.length - MAX_QUEUED_ROOM_SIGNALS);
1933
+ }
1934
+ };
1935
+ return {
1936
+ name,
1937
+ get self() {
1938
+ return session?.hello.handle;
1939
+ },
1940
+ get members() {
1941
+ if (session === void 0) return [];
1942
+ return [...session.members.values()].map((h) => ({
1943
+ handle: h.handle,
1944
+ league: h.league,
1945
+ harness: h.harness,
1946
+ ...h.verified !== void 0 ? { verified: h.verified } : {},
1947
+ ...h.identityVerified !== void 0 ? { identityVerified: h.identityVerified } : {}
1948
+ }));
1949
+ },
1950
+ attach(s) {
1951
+ session = s;
1952
+ s.onMessage(drainMessage);
1953
+ s.onSignal((from, frame) => drainSignal(from, frame));
1954
+ },
1955
+ broadcast(text) {
1956
+ session?.broadcast(text);
1957
+ },
1958
+ async pollMessage(timeoutMs) {
1959
+ if (messageQueue.length > 0) return messageQueue.shift() ?? null;
1960
+ return new Promise((resolve) => {
1961
+ const timer = setTimeout(() => {
1962
+ const idx = messageWaiters.indexOf(resolve);
1963
+ if (idx >= 0) messageWaiters.splice(idx, 1);
1964
+ resolve(null);
1965
+ }, timeoutMs);
1966
+ messageWaiters.push((m) => {
1967
+ clearTimeout(timer);
1968
+ resolve(m);
1969
+ });
1970
+ });
1971
+ },
1972
+ sendSignal(handle2, frame) {
1973
+ session?.sendSignal(handle2, frame);
1974
+ },
1975
+ async pollSignal(handle2, timeoutMs) {
1976
+ const box = signalBoxes.get(handle2);
1977
+ if (box !== void 0 && box.length > 0) return box.shift() ?? null;
1978
+ return new Promise((resolve) => {
1979
+ const timer = setTimeout(() => {
1980
+ const waiters2 = signalWaiters.get(handle2);
1981
+ if (waiters2 !== void 0) {
1982
+ const idx = waiters2.indexOf(resolve);
1983
+ if (idx >= 0) waiters2.splice(idx, 1);
1984
+ if (waiters2.length === 0) signalWaiters.delete(handle2);
1985
+ }
1986
+ resolve(null);
1987
+ }, timeoutMs);
1988
+ let waiters = signalWaiters.get(handle2);
1989
+ if (waiters === void 0) {
1990
+ waiters = [];
1991
+ signalWaiters.set(handle2, waiters);
1992
+ }
1993
+ waiters.push((f) => {
1994
+ clearTimeout(timer);
1995
+ resolve(f);
1996
+ });
1997
+ });
1998
+ }
1999
+ };
2000
+ }
1894
2001
  function currentState(dir) {
1895
2002
  const p = loadProfile(dir);
1896
2003
  if (!p) return { connected: false, candidates: [] };
@@ -2111,11 +2218,101 @@ async function handle(req, res, opts) {
2111
2218
  sendJson(res, 200, { ok: true });
2112
2219
  return;
2113
2220
  }
2221
+ if (req.method === "GET" && pathname === "/api/room") {
2222
+ const room = opts.room;
2223
+ sendJson(res, 200, room ? { room: room.name, self: room.self ?? null, members: room.members } : { room: null, self: null, members: [] });
2224
+ return;
2225
+ }
2226
+ if (req.method === "GET" && pathname === "/room/message") {
2227
+ const room = opts.room;
2228
+ if (!room) {
2229
+ sendJson(res, 200, { message: null, reason: "room-not-attached" });
2230
+ return;
2231
+ }
2232
+ const message = await room.pollMessage(25e3);
2233
+ if (req.destroyed || res.writableEnded) return;
2234
+ sendJson(res, 200, { message });
2235
+ return;
2236
+ }
2237
+ if (req.method === "POST" && pathname === "/room/message") {
2238
+ const room = opts.room;
2239
+ if (!room) {
2240
+ sendJson(res, 400, { error: "room-not-attached" });
2241
+ return;
2242
+ }
2243
+ const body = await readBody(req);
2244
+ let parsed = {};
2245
+ try {
2246
+ parsed = JSON.parse(body);
2247
+ } catch {
2248
+ sendJson(res, 400, { error: "invalid JSON body" });
2249
+ return;
2250
+ }
2251
+ const text = parsed["text"];
2252
+ if (typeof text !== "string") {
2253
+ sendJson(res, 400, { error: "missing text" });
2254
+ return;
2255
+ }
2256
+ const reParsed = parseFrame(
2257
+ JSON.stringify({ t: "msg", id: randomUUID(), text, at: Date.now() })
2258
+ );
2259
+ if (reParsed === null || reParsed.t !== "msg") {
2260
+ sendJson(res, 400, { error: "invalid message text" });
2261
+ return;
2262
+ }
2263
+ room.broadcast(reParsed.text);
2264
+ sendJson(res, 200, { ok: true });
2265
+ return;
2266
+ }
2267
+ if (req.method === "GET" && pathname === "/room/signal") {
2268
+ const room = opts.room;
2269
+ if (!room) {
2270
+ sendJson(res, 200, { frame: null, reason: "room-not-attached" });
2271
+ return;
2272
+ }
2273
+ const handle2 = url.searchParams.get("handle") ?? "";
2274
+ if (handle2 === "") {
2275
+ sendJson(res, 400, { error: "missing handle" });
2276
+ return;
2277
+ }
2278
+ const frame = await room.pollSignal(handle2, 25e3);
2279
+ if (req.destroyed || res.writableEnded) return;
2280
+ sendJson(res, 200, { frame });
2281
+ return;
2282
+ }
2283
+ if (req.method === "POST" && pathname === "/room/signal") {
2284
+ const room = opts.room;
2285
+ if (!room) {
2286
+ sendJson(res, 400, { error: "room-not-attached" });
2287
+ return;
2288
+ }
2289
+ const body = await readBody(req);
2290
+ let parsed = {};
2291
+ try {
2292
+ parsed = JSON.parse(body);
2293
+ } catch {
2294
+ sendJson(res, 400, { error: "invalid JSON body" });
2295
+ return;
2296
+ }
2297
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
2298
+ if (handle2 === "") {
2299
+ sendJson(res, 400, { error: "missing handle" });
2300
+ return;
2301
+ }
2302
+ const reParsed = parseFrame(JSON.stringify(parsed["frame"]));
2303
+ if (reParsed === null || reParsed.t !== "rtc-offer" && reParsed.t !== "rtc-answer" && reParsed.t !== "rtc-ice") {
2304
+ sendJson(res, 400, { error: "invalid rtc frame" });
2305
+ return;
2306
+ }
2307
+ room.sendSignal(handle2, reParsed);
2308
+ sendJson(res, 200, { ok: true });
2309
+ return;
2310
+ }
2114
2311
  sendJson(res, 404, { error: "not found" });
2115
2312
  }
2116
2313
 
2117
2314
  // src/cli.ts
2118
- var VERSION = "0.6.0";
2315
+ var VERSION = "0.7.0";
2119
2316
  function parsePort(raw) {
2120
2317
  const n = Number(raw);
2121
2318
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -2131,7 +2328,8 @@ function parseArgs(argv) {
2131
2328
  arg: void 0,
2132
2329
  to: void 0,
2133
2330
  keepAlive: false,
2134
- viaRelay: false
2331
+ viaRelay: false,
2332
+ room: void 0
2135
2333
  };
2136
2334
  for (let i = 0; i < argv.length; i++) {
2137
2335
  const a = argv[i];
@@ -2145,7 +2343,8 @@ function parseArgs(argv) {
2145
2343
  arg: void 0,
2146
2344
  to: void 0,
2147
2345
  keepAlive: false,
2148
- viaRelay: false
2346
+ viaRelay: false,
2347
+ room: void 0
2149
2348
  };
2150
2349
  }
2151
2350
  if (a === "--help" || a === "-h") {
@@ -2158,7 +2357,8 @@ function parseArgs(argv) {
2158
2357
  arg: void 0,
2159
2358
  to: void 0,
2160
2359
  keepAlive: false,
2161
- viaRelay: false
2360
+ viaRelay: false,
2361
+ room: void 0
2162
2362
  };
2163
2363
  }
2164
2364
  if (a === "--live") {
@@ -2173,6 +2373,18 @@ function parseArgs(argv) {
2173
2373
  out = { ...out, viaRelay: true };
2174
2374
  continue;
2175
2375
  }
2376
+ if (a === "--room") {
2377
+ const next = argv[i + 1];
2378
+ if (next !== void 0) {
2379
+ out = { ...out, room: next };
2380
+ i++;
2381
+ }
2382
+ continue;
2383
+ }
2384
+ if (a.startsWith("--room=")) {
2385
+ out = { ...out, room: a.slice("--room=".length) };
2386
+ continue;
2387
+ }
2176
2388
  if (a === "--any") {
2177
2389
  out = { ...out, any: true };
2178
2390
  continue;
@@ -2208,7 +2420,7 @@ function parseArgs(argv) {
2208
2420
  continue;
2209
2421
  }
2210
2422
  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;
2423
+ 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
2424
  if (known !== null && out.command === null) {
2213
2425
  out = { ...out, command: known };
2214
2426
  } else if (out.arg === void 0) {
@@ -2452,36 +2664,59 @@ async function cmdDiscover(live, any, viaRelay) {
2452
2664
  );
2453
2665
  return 0;
2454
2666
  }
2455
- async function cmdOpen(port, any) {
2667
+ async function cmdOpen(port, any, room) {
2456
2668
  const profile = loadProfile();
2457
2669
  let live;
2670
+ let roomBridge;
2458
2671
  let session;
2672
+ let roomSession;
2459
2673
  if (profile) {
2460
2674
  if (!canShareLive()) grantLiveConsent();
2461
- live = createLiveBridge();
2675
+ if (room !== void 0) {
2676
+ roomBridge = createRoomBridge(room);
2677
+ } else {
2678
+ live = createLiveBridge();
2679
+ }
2462
2680
  }
2463
- const started = await startServer({ port, live });
2464
- if (profile && live) {
2681
+ const started = await startServer({
2682
+ port,
2683
+ live,
2684
+ ...roomBridge === void 0 ? {} : { room: roomBridge }
2685
+ });
2686
+ if (profile) {
2465
2687
  process2.stdout.write(`
2466
2688
  ${LIVE_NOTICE}
2467
2689
  `);
2468
2690
  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
- });
2691
+ if (room !== void 0 && roomBridge !== void 0) {
2692
+ void startRoom({ hello, room, isBlocked: blockedChecker() }).then((s) => {
2693
+ roomSession = s;
2694
+ roomBridge.attach(s);
2695
+ }).catch(() => {
2696
+ });
2697
+ } else if (live) {
2698
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
2699
+ void startDiscovery({
2700
+ hello,
2701
+ topics,
2702
+ acceptLeague,
2703
+ isBlocked: blockedChecker(),
2704
+ onLink: (link) => live.addLink(link)
2705
+ }).then((s) => {
2706
+ session = s;
2707
+ }).catch(() => {
2708
+ });
2709
+ }
2480
2710
  }
2481
2711
  process2.stdout.write(`
2482
2712
  vibedating local web app \u2192 ${started.url}
2483
2713
  `);
2484
- if (live) {
2714
+ if (room !== void 0) {
2715
+ process2.stdout.write(
2716
+ ` \u2022 room: ${room} \u2014 roster + group chat + full-mesh group video (~6 people; an SFU is the upgrade path for bigger rooms)
2717
+ `
2718
+ );
2719
+ } else if (live) {
2485
2720
  process2.stdout.write(
2486
2721
  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
2722
  );
@@ -2495,6 +2730,7 @@ async function cmdOpen(port, any) {
2495
2730
  process2.once("SIGTERM", () => resolve());
2496
2731
  });
2497
2732
  process2.stdout.write("\n shutting down\u2026\n");
2733
+ if (roomSession) await roomSession.close();
2498
2734
  if (session) await session.close();
2499
2735
  await new Promise((resolve) => started.server.close(() => resolve()));
2500
2736
  return 0;
@@ -2717,6 +2953,104 @@ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2717
2953
  process2.stdout.write("\n");
2718
2954
  return 0;
2719
2955
  }
2956
+ async function cmdRoom(name, keepAlive) {
2957
+ if (name === void 0 || name.trim() === "") {
2958
+ process2.stderr.write("usage: vibedating room <name>\n");
2959
+ return 1;
2960
+ }
2961
+ const profile = loadProfile();
2962
+ if (!profile) {
2963
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
2964
+ return 1;
2965
+ }
2966
+ if (!canShareLive()) grantLiveConsent();
2967
+ if (!canShareLive()) {
2968
+ process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
2969
+ return 1;
2970
+ }
2971
+ const hello = buildHello(profile);
2972
+ process2.stdout.write("\n");
2973
+ process2.stdout.write(` ${LIVE_NOTICE}
2974
+ `);
2975
+ process2.stdout.write(` ${MARKS_LEGEND}
2976
+ `);
2977
+ process2.stdout.write(` room: ${name} (cross-league \u2014 everyone in the room is a member)
2978
+ `);
2979
+ const session = await startRoom({
2980
+ hello,
2981
+ room: name,
2982
+ isBlocked: blockedChecker()
2983
+ });
2984
+ session.onRoster((members) => {
2985
+ if (members.length === 0) {
2986
+ process2.stdout.write(" \xB7 room empty \u2014 waiting for members\u2026\n");
2987
+ return;
2988
+ }
2989
+ const list = members.map(
2990
+ (m) => `${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}`
2991
+ ).join(", ");
2992
+ process2.stdout.write(` \xB7 room (${members.length}): ${list}
2993
+ `);
2994
+ });
2995
+ session.onMessage((m) => {
2996
+ process2.stdout.write(` <${sanitizePeerText(m.from)}> ${sanitizePeerText(m.text)}
2997
+ `);
2998
+ });
2999
+ process2.stdout.write(
3000
+ ` topic: ${ROOM_TOPIC_PREFIX}${name} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
3001
+ `
3002
+ );
3003
+ process2.stdout.write(" type to broadcast to the whole room \xB7 /who \xB7 /quit\n");
3004
+ process2.stdout.write(` group video: run \`vibedating open --room ${name}\`
3005
+
3006
+ `);
3007
+ const rl = readline.createInterface({ input: process2.stdin, terminal: false });
3008
+ const stop = () => {
3009
+ rl.close();
3010
+ };
3011
+ process2.once("SIGINT", stop);
3012
+ process2.once("SIGTERM", stop);
3013
+ let quitRequested = false;
3014
+ for await (const line of rl) {
3015
+ const text = line.trim();
3016
+ if (text === "/quit") {
3017
+ quitRequested = true;
3018
+ break;
3019
+ }
3020
+ if (text === "/who") {
3021
+ const members = [...session.members.values()];
3022
+ if (members.length === 0) {
3023
+ process2.stdout.write(" \xB7 room empty\n");
3024
+ } else {
3025
+ for (const m of members) {
3026
+ process2.stdout.write(
3027
+ ` \xB7 ${sanitizePeerText(m.handle)} (${m.league} \xB7 ${m.harness}) ${usageMark(m)}${idMark(m)}
3028
+ `
3029
+ );
3030
+ }
3031
+ }
3032
+ continue;
3033
+ }
3034
+ if (text === "") continue;
3035
+ const reached = session.broadcast(text);
3036
+ if (reached.length === 0) {
3037
+ process2.stdout.write(" \xB7 room empty \u2014 no one to hear you yet\n");
3038
+ }
3039
+ }
3040
+ if (!quitRequested && shouldKeepAlive(keepAlive, process2.stdin.isTTY)) {
3041
+ process2.stdout.write(" stdin closed \u2014 staying in the room (Ctrl+C / SIGTERM to leave)\n");
3042
+ await new Promise((resolve) => {
3043
+ process2.once("SIGINT", () => resolve());
3044
+ process2.once("SIGTERM", () => resolve());
3045
+ });
3046
+ }
3047
+ process2.removeListener("SIGINT", stop);
3048
+ process2.removeListener("SIGTERM", stop);
3049
+ process2.stdout.write("\n leaving the room\u2026\n");
3050
+ await session.close();
3051
+ process2.stdout.write("\n");
3052
+ return 0;
3053
+ }
2720
3054
  async function cmdFind(targetArg, any) {
2721
3055
  if (targetArg === void 0 || targetArg.trim() === "") {
2722
3056
  process2.stderr.write("usage: vibedating find <@handle> [--any]\n");
@@ -2968,9 +3302,15 @@ Usage:
2968
3302
  NEW matches, never opens chat/video. install adds a
2969
3303
  login service (launchd on macOS, systemd on Linux);
2970
3304
  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)
3305
+ vibedating room <name> Join/create a named room (multi-peer): live roster
3306
+ + group text chat broadcast to all members.
3307
+ Consent-gated like live. /who lists members, /quit leaves.
3308
+ vibedating open [--port N] [--any] [--room <name>] Serve the local web app (default:
3309
+ random port) + live video + chat with connected peers
3310
+ (your league + adjacent; --any = every league).
3311
+ --room <name> opens the room view instead: roster +
3312
+ group chat + full-mesh group video (~6 people; an SFU
3313
+ is the upgrade path for bigger rooms).
2974
3314
  vibedating mcp Run the stdio MCP server (profile, matches)
2975
3315
  vibedating --version
2976
3316
  vibedating --help
@@ -3022,11 +3362,13 @@ async function main(argv) {
3022
3362
  case "discover":
3023
3363
  return cmdDiscover(parsed.live, parsed.any, parsed.viaRelay);
3024
3364
  case "open":
3025
- return cmdOpen(parsed.port, parsed.any);
3365
+ return cmdOpen(parsed.port, parsed.any, parsed.room);
3026
3366
  case "live":
3027
3367
  return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive, parsed.viaRelay);
3028
3368
  case "find":
3029
3369
  return cmdFind(parsed.arg, parsed.any);
3370
+ case "room":
3371
+ return cmdRoom(parsed.arg ?? parsed.room, parsed.keepAlive);
3030
3372
  case "mcp":
3031
3373
  await runMcp();
3032
3374
  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.0",
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",