vibedate 0.5.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.
package/dist/cli.js CHANGED
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-I5U3O4RH.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,
13
14
  canShareLive,
14
15
  connectProfile,
16
+ createNostrPoolTransport,
17
+ createNostrRelayLink,
15
18
  defaultStateDir,
16
19
  grantLiveConsent,
17
20
  isBlocked,
@@ -22,6 +25,7 @@ import {
22
25
  loadBlocklist,
23
26
  loadHandle,
24
27
  loadOrCreateIdentity,
28
+ loadOrCreateNostrKey,
25
29
  loadPeers,
26
30
  loadProfile,
27
31
  matches,
@@ -34,8 +38,9 @@ import {
34
38
  sanitizePeerText,
35
39
  saveHandle,
36
40
  signHelloClaims,
37
- startDiscovery
38
- } from "./chunk-KKWP4DLY.js";
41
+ startDiscovery,
42
+ startRoom
43
+ } from "./chunk-HDHJLZZG.js";
39
44
 
40
45
  // src/cli.ts
41
46
  import readline from "readline";
@@ -1888,6 +1893,111 @@ function createLiveBridge() {
1888
1893
  };
1889
1894
  return bridge;
1890
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
+ }
1891
2001
  function currentState(dir) {
1892
2002
  const p = loadProfile(dir);
1893
2003
  if (!p) return { connected: false, candidates: [] };
@@ -2108,11 +2218,101 @@ async function handle(req, res, opts) {
2108
2218
  sendJson(res, 200, { ok: true });
2109
2219
  return;
2110
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
+ }
2111
2311
  sendJson(res, 404, { error: "not found" });
2112
2312
  }
2113
2313
 
2114
2314
  // src/cli.ts
2115
- var VERSION = "0.5.0";
2315
+ var VERSION = "0.7.0";
2116
2316
  function parsePort(raw) {
2117
2317
  const n = Number(raw);
2118
2318
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -2127,7 +2327,9 @@ function parseArgs(argv) {
2127
2327
  any: false,
2128
2328
  arg: void 0,
2129
2329
  to: void 0,
2130
- keepAlive: false
2330
+ keepAlive: false,
2331
+ viaRelay: false,
2332
+ room: void 0
2131
2333
  };
2132
2334
  for (let i = 0; i < argv.length; i++) {
2133
2335
  const a = argv[i];
@@ -2140,7 +2342,9 @@ function parseArgs(argv) {
2140
2342
  any: false,
2141
2343
  arg: void 0,
2142
2344
  to: void 0,
2143
- keepAlive: false
2345
+ keepAlive: false,
2346
+ viaRelay: false,
2347
+ room: void 0
2144
2348
  };
2145
2349
  }
2146
2350
  if (a === "--help" || a === "-h") {
@@ -2152,7 +2356,9 @@ function parseArgs(argv) {
2152
2356
  any: false,
2153
2357
  arg: void 0,
2154
2358
  to: void 0,
2155
- keepAlive: false
2359
+ keepAlive: false,
2360
+ viaRelay: false,
2361
+ room: void 0
2156
2362
  };
2157
2363
  }
2158
2364
  if (a === "--live") {
@@ -2163,6 +2369,22 @@ function parseArgs(argv) {
2163
2369
  out = { ...out, keepAlive: true };
2164
2370
  continue;
2165
2371
  }
2372
+ if (a === "--via-relay") {
2373
+ out = { ...out, viaRelay: true };
2374
+ continue;
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
+ }
2166
2388
  if (a === "--any") {
2167
2389
  out = { ...out, any: true };
2168
2390
  continue;
@@ -2198,7 +2420,7 @@ function parseArgs(argv) {
2198
2420
  continue;
2199
2421
  }
2200
2422
  if (a.startsWith("-")) continue;
2201
- 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;
2202
2424
  if (known !== null && out.command === null) {
2203
2425
  out = { ...out, command: known };
2204
2426
  } else if (out.arg === void 0) {
@@ -2381,7 +2603,7 @@ async function cmdMatches(live) {
2381
2603
  process2.stdout.write("\n");
2382
2604
  return 0;
2383
2605
  }
2384
- async function cmdDiscover(live, any) {
2606
+ async function cmdDiscover(live, any, viaRelay) {
2385
2607
  const profile = loadProfile();
2386
2608
  if (!profile) {
2387
2609
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -2400,6 +2622,9 @@ async function cmdDiscover(live, any) {
2400
2622
  `);
2401
2623
  process2.stdout.write(` ${MARKS_LEGEND}
2402
2624
  `);
2625
+ if (viaRelay) {
2626
+ process2.stdout.write(" \u2022 --via-relay: the relay fallback reaches a KNOWN peer over Nostr \u2014 use `live --via-relay --to @handle`\n");
2627
+ }
2403
2628
  const { topics, acceptLeague } = discoveryScope(profile.league, any);
2404
2629
  const session = await startDiscovery({
2405
2630
  hello,
@@ -2439,36 +2664,59 @@ async function cmdDiscover(live, any) {
2439
2664
  );
2440
2665
  return 0;
2441
2666
  }
2442
- async function cmdOpen(port, any) {
2667
+ async function cmdOpen(port, any, room) {
2443
2668
  const profile = loadProfile();
2444
2669
  let live;
2670
+ let roomBridge;
2445
2671
  let session;
2672
+ let roomSession;
2446
2673
  if (profile) {
2447
2674
  if (!canShareLive()) grantLiveConsent();
2448
- live = createLiveBridge();
2675
+ if (room !== void 0) {
2676
+ roomBridge = createRoomBridge(room);
2677
+ } else {
2678
+ live = createLiveBridge();
2679
+ }
2449
2680
  }
2450
- const started = await startServer({ port, live });
2451
- if (profile && live) {
2681
+ const started = await startServer({
2682
+ port,
2683
+ live,
2684
+ ...roomBridge === void 0 ? {} : { room: roomBridge }
2685
+ });
2686
+ if (profile) {
2452
2687
  process2.stdout.write(`
2453
2688
  ${LIVE_NOTICE}
2454
2689
  `);
2455
2690
  const hello = buildHello(profile);
2456
- const { topics, acceptLeague } = discoveryScope(profile.league, any);
2457
- void startDiscovery({
2458
- hello,
2459
- topics,
2460
- acceptLeague,
2461
- isBlocked: blockedChecker(),
2462
- onLink: (link) => live.addLink(link)
2463
- }).then((s) => {
2464
- session = s;
2465
- }).catch(() => {
2466
- });
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
+ }
2467
2710
  }
2468
2711
  process2.stdout.write(`
2469
2712
  vibedating local web app \u2192 ${started.url}
2470
2713
  `);
2471
- 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) {
2472
2720
  process2.stdout.write(
2473
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"
2474
2722
  );
@@ -2482,6 +2730,7 @@ async function cmdOpen(port, any) {
2482
2730
  process2.once("SIGTERM", () => resolve());
2483
2731
  });
2484
2732
  process2.stdout.write("\n shutting down\u2026\n");
2733
+ if (roomSession) await roomSession.close();
2485
2734
  if (session) await session.close();
2486
2735
  await new Promise((resolve) => started.server.close(() => resolve()));
2487
2736
  return 0;
@@ -2489,7 +2738,100 @@ async function cmdOpen(port, any) {
2489
2738
  function shouldKeepAlive(flag, stdinIsTTY) {
2490
2739
  return flag || stdinIsTTY !== true;
2491
2740
  }
2492
- async function cmdLive(dating, any, to, keepAlive) {
2741
+ async function cmdLiveViaRelay(profile, to) {
2742
+ const target = to !== void 0 ? normalizeHandle(to) : null;
2743
+ if (to !== void 0 && target === null) {
2744
+ process2.stderr.write(`invalid target handle: ${to}
2745
+ `);
2746
+ return 1;
2747
+ }
2748
+ if (target === null) {
2749
+ process2.stderr.write(
2750
+ "relay mode (v0) targets one known peer \u2014 use: vibedating live --via-relay --to <@handle>\n(discover the peer first with `vibedating discover --live`, then reach them over the relay).\n"
2751
+ );
2752
+ return 1;
2753
+ }
2754
+ const peer = loadPeers().find((p) => sameHandle(p.handle, target) && typeof p.pubkey === "string");
2755
+ if (peer === void 0 || peer.pubkey === void 0) {
2756
+ process2.stderr.write(
2757
+ `${target} is not a known peer with an identity pubkey. Run \`vibedating discover --live\` first to meet them over the DHT, then retry --via-relay.
2758
+ `
2759
+ );
2760
+ return 1;
2761
+ }
2762
+ const identity = loadOrCreateIdentity();
2763
+ process2.stdout.write("\n");
2764
+ process2.stdout.write(` ${LIVE_NOTICE}
2765
+ `);
2766
+ process2.stdout.write(
2767
+ ` routing e2e chat to ${sanitizePeerText(peer.handle)} through public Nostr relays (direct hole-punch fallback)
2768
+ `
2769
+ );
2770
+ process2.stdout.write(" \u2022 the relay stores only ciphertext \u2014 it cannot read your chat\n");
2771
+ const myNostr = await loadOrCreateNostrKey();
2772
+ const transport = await createNostrPoolTransport();
2773
+ const link = await createNostrRelayLink({
2774
+ myNostr,
2775
+ myEd25519Hex: identity.publicKeyHex,
2776
+ peerEd25519Hex: peer.pubkey,
2777
+ hello: peer,
2778
+ transport
2779
+ });
2780
+ const pairing = createPairing();
2781
+ pairing.onMatch((l) => {
2782
+ if (l === void 0) {
2783
+ process2.stdout.write(" \xB7 relay peer gone\n");
2784
+ return;
2785
+ }
2786
+ const { qual } = peerDirection(profile.league, l.hello.league);
2787
+ process2.stdout.write(
2788
+ ` \xB7 relayed to ${sanitizePeerText(l.hello.handle)} (${l.hello.league}${qual} \xB7 ${l.hello.harness}) ${usageMark(l.hello)}${idMark(l.hello)}
2789
+ `
2790
+ );
2791
+ l.onMessage((m) => {
2792
+ process2.stdout.write(` <${sanitizePeerText(l.hello.handle)}> ${sanitizePeerText(m.text)}
2793
+ `);
2794
+ });
2795
+ });
2796
+ pairing.add(link);
2797
+ process2.stdout.write(" type to chat \xB7 /quit\n");
2798
+ process2.stdout.write(" (Ctrl+C to stop)\n\n");
2799
+ const rl = readline.createInterface({ input: process2.stdin, terminal: false });
2800
+ const stop = () => {
2801
+ rl.close();
2802
+ };
2803
+ process2.once("SIGINT", stop);
2804
+ process2.once("SIGTERM", stop);
2805
+ let quitRequested = false;
2806
+ for await (const line of rl) {
2807
+ const text = line.trim();
2808
+ if (text === "/quit") {
2809
+ quitRequested = true;
2810
+ break;
2811
+ }
2812
+ if (text === "/next") {
2813
+ process2.stdout.write(" \xB7 relay mode targets one peer \u2014 /next re-announces presence\n");
2814
+ continue;
2815
+ }
2816
+ if (text === "") continue;
2817
+ const cur2 = pairing.current();
2818
+ if (cur2 !== void 0) {
2819
+ cur2.send(text);
2820
+ } else {
2821
+ process2.stdout.write(" \xB7 no relay peer yet \u2014 waiting for the presence exchange\u2026\n");
2822
+ }
2823
+ }
2824
+ process2.removeListener("SIGINT", stop);
2825
+ process2.removeListener("SIGTERM", stop);
2826
+ void quitRequested;
2827
+ const cur = pairing.current();
2828
+ if (cur !== void 0) cur.close();
2829
+ process2.stdout.write("\n closing relay link\u2026\n");
2830
+ link.close();
2831
+ process2.stdout.write("\n");
2832
+ return 0;
2833
+ }
2834
+ async function cmdLive(dating, any, to, keepAlive, viaRelay) {
2493
2835
  const profile = loadProfile();
2494
2836
  if (!profile) {
2495
2837
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -2506,6 +2848,7 @@ async function cmdLive(dating, any, to, keepAlive) {
2506
2848
  process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
2507
2849
  return 1;
2508
2850
  }
2851
+ if (viaRelay) return cmdLiveViaRelay(profile, to);
2509
2852
  const hello = buildHello(profile);
2510
2853
  process2.stdout.write("\n");
2511
2854
  process2.stdout.write(` ${LIVE_NOTICE}
@@ -2610,6 +2953,104 @@ async function cmdLive(dating, any, to, keepAlive) {
2610
2953
  process2.stdout.write("\n");
2611
2954
  return 0;
2612
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
+ }
2613
3054
  async function cmdFind(targetArg, any) {
2614
3055
  if (targetArg === void 0 || targetArg.trim() === "") {
2615
3056
  process2.stderr.write("usage: vibedating find <@handle> [--any]\n");
@@ -2846,10 +3287,11 @@ Usage:
2846
3287
  vibedating connect Read your usage, compute + print your league
2847
3288
  vibedating matches [--live] List candidates in your league (live peers if any)
2848
3289
  vibedating discover [--live] [--any] Find live peers over the DHT (your league + adjacent; --any = everyone)
2849
- vibedating live [--dating] [--any] [--to @handle] [--keep-alive] Live chat (your league
3290
+ vibedating live [--dating] [--any] [--to @handle] [--keep-alive] [--via-relay] Live chat (your league
2850
3291
  + adjacent; --any = everyone; /next or --dating pick;
2851
3292
  --to targets one peer; --keep-alive survives stdin EOF \u2014
2852
- automatic when stdin is not a TTY)
3293
+ automatic when stdin is not a TTY; --via-relay routes e2e chat
3294
+ through public Nostr relays when direct hole-punching fails)
2853
3295
  vibedating find <@handle> [--any] Search the DHT for one specific handle (\u2605 highlights a match)
2854
3296
  vibedating handle [@name] Print or set your handle (persisted; a leading '@' is optional)
2855
3297
  vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
@@ -2860,9 +3302,15 @@ Usage:
2860
3302
  NEW matches, never opens chat/video. install adds a
2861
3303
  login service (launchd on macOS, systemd on Linux);
2862
3304
  uninstall removes it.
2863
- vibedating open [--port N] [--any] Serve the local web app (default: random port)
2864
- + live video + chat with connected peers
2865
- (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).
2866
3314
  vibedating mcp Run the stdio MCP server (profile, matches)
2867
3315
  vibedating --version
2868
3316
  vibedating --help
@@ -2912,13 +3360,15 @@ async function main(argv) {
2912
3360
  case "matches":
2913
3361
  return cmdMatches(parsed.live);
2914
3362
  case "discover":
2915
- return cmdDiscover(parsed.live, parsed.any);
3363
+ return cmdDiscover(parsed.live, parsed.any, parsed.viaRelay);
2916
3364
  case "open":
2917
- return cmdOpen(parsed.port, parsed.any);
3365
+ return cmdOpen(parsed.port, parsed.any, parsed.room);
2918
3366
  case "live":
2919
- return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive);
3367
+ return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive, parsed.viaRelay);
2920
3368
  case "find":
2921
3369
  return cmdFind(parsed.arg, parsed.any);
3370
+ case "room":
3371
+ return cmdRoom(parsed.arg ?? parsed.room, parsed.keepAlive);
2922
3372
  case "mcp":
2923
3373
  await runMcp();
2924
3374
  return 0;