vibedate 0.4.0 → 0.5.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,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-ZB56ZBAR.js";
4
+ } from "./chunk-I5U3O4RH.js";
5
5
  import {
6
6
  CANDIDATES,
7
+ DEFAULT_HANDLE,
7
8
  LIVE_NOTICE,
9
+ MAX_HANDLE_LEN,
8
10
  TOPIC_PREFIX,
9
11
  addBlock,
10
12
  allLeagueNames,
11
13
  canShareLive,
12
14
  connectProfile,
15
+ defaultStateDir,
13
16
  grantLiveConsent,
14
17
  isBlocked,
15
18
  league,
@@ -17,6 +20,7 @@ import {
17
20
  leagueTopic,
18
21
  leaguesWithin,
19
22
  loadBlocklist,
23
+ loadHandle,
20
24
  loadOrCreateIdentity,
21
25
  loadPeers,
22
26
  loadProfile,
@@ -27,16 +31,356 @@ import {
27
31
  removeBlock,
28
32
  resolveHandle,
29
33
  sameHandle,
34
+ sanitizePeerText,
30
35
  saveHandle,
31
36
  signHelloClaims,
32
37
  startDiscovery
33
- } from "./chunk-43IYNWES.js";
38
+ } from "./chunk-KKWP4DLY.js";
34
39
 
35
40
  // src/cli.ts
36
41
  import readline from "readline";
37
42
  import "url";
38
43
  import process2 from "process";
39
44
 
45
+ // src/handlegen.ts
46
+ import { randomBytes } from "crypto";
47
+ var FIRST = [
48
+ "segfault",
49
+ "yak",
50
+ "vibe",
51
+ "null",
52
+ "async",
53
+ "await",
54
+ "heap",
55
+ "stack",
56
+ "sudo",
57
+ "regex",
58
+ "token",
59
+ "prompt",
60
+ "context",
61
+ "merge",
62
+ "rebase",
63
+ "hotfix",
64
+ "flaky",
65
+ "cursed",
66
+ "based",
67
+ "quantum",
68
+ "turbo",
69
+ "neural",
70
+ "agentic",
71
+ "kernel",
72
+ "docker",
73
+ "kube",
74
+ "lambda",
75
+ "pointer",
76
+ "buffer",
77
+ "packet",
78
+ "syscall",
79
+ "runtime",
80
+ "monad",
81
+ "borrow",
82
+ "cache",
83
+ "deadlock",
84
+ "localhost",
85
+ "darkmode",
86
+ "wasm",
87
+ "diff"
88
+ ];
89
+ var SECOND = [
90
+ "sommelier",
91
+ "shaver",
92
+ "goblin",
93
+ "nomad",
94
+ "gremlin",
95
+ "wizard",
96
+ "bard",
97
+ "pirate",
98
+ "ninja",
99
+ "monk",
100
+ "prophet",
101
+ "oracle",
102
+ "smith",
103
+ "farmer",
104
+ "whisperer",
105
+ "tamer",
106
+ "connoisseur",
107
+ "maximalist",
108
+ "enjoyer",
109
+ "dealer",
110
+ "herder",
111
+ "wrangler",
112
+ "juggler",
113
+ "mechanic",
114
+ "surgeon",
115
+ "detective",
116
+ "librarian",
117
+ "alchemist",
118
+ "overlord",
119
+ "apprentice",
120
+ "sensei",
121
+ "chef",
122
+ "dj",
123
+ "ranger",
124
+ "paladin",
125
+ "barbarian",
126
+ "summoner",
127
+ "cartographer",
128
+ "archivist",
129
+ "plumber"
130
+ ];
131
+ var SUFFIX = [
132
+ "prime",
133
+ "9000",
134
+ "3000",
135
+ "max",
136
+ "ultra",
137
+ "xl",
138
+ "mk2",
139
+ "777",
140
+ "404",
141
+ "1337",
142
+ "2077",
143
+ "xd"
144
+ ];
145
+ function cryptoRand() {
146
+ return randomBytes(4).readUInt32BE(0) / 2 ** 32;
147
+ }
148
+ function pick(list, rand) {
149
+ return list[Math.min(list.length - 1, Math.floor(rand() * list.length))];
150
+ }
151
+ function generateHandle(rand = cryptoRand) {
152
+ const first = pick(FIRST, rand);
153
+ const second = pick(SECOND, rand);
154
+ let body = `${first}_${second}`;
155
+ if (rand() < 0.5) {
156
+ const withSuffix = `${body}_${pick(SUFFIX, rand)}`;
157
+ if (withSuffix.length + 1 <= MAX_HANDLE_LEN) body = withSuffix;
158
+ }
159
+ const canonical = normalizeHandle(body);
160
+ if (canonical === null) throw new Error(`handle generator produced an invalid handle: ${body}`);
161
+ return canonical;
162
+ }
163
+ function ensureHandle(dir = defaultStateDir()) {
164
+ const env = process.env["VIBEDATING_HANDLE"];
165
+ if (env !== void 0 && env.trim() !== "") {
166
+ const canonical = normalizeHandle(env);
167
+ if (canonical !== null) return { handle: canonical, generated: false };
168
+ }
169
+ const persisted = loadHandle(dir);
170
+ if (persisted !== DEFAULT_HANDLE) return { handle: persisted, generated: false };
171
+ const generated = generateHandle();
172
+ return { handle: saveHandle(generated, dir), generated: true };
173
+ }
174
+
175
+ // src/daemon.ts
176
+ import { spawn, spawnSync } from "child_process";
177
+ import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "fs";
178
+ import os from "os";
179
+ import path from "path";
180
+ function daemonStatePath(dir) {
181
+ return path.join(dir, "daemon.json");
182
+ }
183
+ function daemonLogPath(dir) {
184
+ return path.join(dir, "daemon.log");
185
+ }
186
+ function readDaemonState(dir = defaultStateDir()) {
187
+ try {
188
+ const raw = readFileSync(daemonStatePath(dir), "utf8");
189
+ const data = JSON.parse(raw);
190
+ if (typeof data["pid"] !== "number" || !Number.isInteger(data["pid"]) || data["pid"] <= 0 || typeof data["startedAt"] !== "string" || typeof data["any"] !== "boolean" || typeof data["version"] !== "string") {
191
+ return null;
192
+ }
193
+ return { pid: data["pid"], startedAt: data["startedAt"], any: data["any"], version: data["version"] };
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ function writeDaemonState(dir, state) {
199
+ mkdirSync(dir, { recursive: true });
200
+ writeFileSync(daemonStatePath(dir), JSON.stringify(state, null, 2) + "\n", "utf8");
201
+ }
202
+ function removeDaemonState(dir = defaultStateDir()) {
203
+ try {
204
+ rmSync(daemonStatePath(dir), { force: true });
205
+ } catch {
206
+ }
207
+ }
208
+ function isPidAlive(pid, kill = process.kill) {
209
+ try {
210
+ kill(pid, 0);
211
+ return true;
212
+ } catch (err) {
213
+ return err.code === "EPERM";
214
+ }
215
+ }
216
+ function daemonStatus(dir = defaultStateDir(), alive = isPidAlive) {
217
+ const state = readDaemonState(dir);
218
+ if (state === null) return { running: false, state: null };
219
+ if (alive(state.pid)) return { running: true, state };
220
+ removeDaemonState(dir);
221
+ return { running: false, state: null };
222
+ }
223
+ function defaultSpawn(execPath, scriptPath, args, logPath) {
224
+ mkdirSync(path.dirname(logPath), { recursive: true });
225
+ const fd = openSync(logPath, "a");
226
+ const child = spawn(execPath, [scriptPath, ...args], {
227
+ detached: true,
228
+ stdio: ["ignore", fd, fd]
229
+ });
230
+ child.unref();
231
+ if (child.pid === void 0) throw new Error("could not spawn daemon child process");
232
+ return child.pid;
233
+ }
234
+ function startDaemon(opts) {
235
+ const dir = opts.dir ?? defaultStateDir();
236
+ const status = daemonStatus(dir, opts.alive);
237
+ if (status.running && status.state !== null) {
238
+ return { started: false, reason: `already running (pid ${status.state.pid})` };
239
+ }
240
+ const execPath = opts.execPath ?? process.execPath;
241
+ const scriptPath = opts.scriptPath ?? process.argv[1];
242
+ if (scriptPath === void 0) return { started: false, reason: "cannot locate the CLI entry point" };
243
+ const args = ["daemon", "run", ...opts.any ? ["--any"] : []];
244
+ const spawnProcess = opts.spawnProcess ?? ((a, logPath) => defaultSpawn(execPath, scriptPath, [...a], logPath));
245
+ const pid = spawnProcess(args, daemonLogPath(dir));
246
+ writeDaemonState(dir, { pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), any: opts.any, version: opts.version });
247
+ return { started: true, pid };
248
+ }
249
+ async function stopDaemon(opts = {}) {
250
+ const dir = opts.dir ?? defaultStateDir();
251
+ const kill = opts.kill ?? process.kill;
252
+ const alive = opts.alive ?? ((pid) => isPidAlive(pid, kill));
253
+ const state = readDaemonState(dir);
254
+ if (state === null) return { stopped: false, reason: "not running" };
255
+ if (!alive(state.pid)) {
256
+ removeDaemonState(dir);
257
+ return { stopped: false, reason: "not running (cleaned stale pidfile)" };
258
+ }
259
+ try {
260
+ kill(state.pid, "SIGTERM");
261
+ } catch {
262
+ }
263
+ const deadline = Date.now() + (opts.waitMs ?? 2e3);
264
+ while (Date.now() < deadline && alive(state.pid)) {
265
+ await new Promise((r) => setTimeout(r, 50));
266
+ }
267
+ removeDaemonState(dir);
268
+ return { stopped: true, pid: state.pid };
269
+ }
270
+ var DAEMON_SERVICE_LABEL = "ai.vibedating.daemon";
271
+ var SYSTEMD_UNIT_NAME = "vibedating.service";
272
+ function daemonServicePath(platform = process.platform, homeDir = os.homedir()) {
273
+ if (platform === "darwin") {
274
+ return path.join(homeDir, "Library", "LaunchAgents", `${DAEMON_SERVICE_LABEL}.plist`);
275
+ }
276
+ if (platform === "linux") {
277
+ return path.join(homeDir, ".config", "systemd", "user", SYSTEMD_UNIT_NAME);
278
+ }
279
+ return null;
280
+ }
281
+ function serviceArgv(execPath, scriptPath, any) {
282
+ return [execPath, scriptPath, "daemon", "run", ...any ? ["--any"] : []];
283
+ }
284
+ function renderLaunchdPlist(opts) {
285
+ const args = serviceArgv(opts.execPath, opts.scriptPath, opts.any).map((a) => ` <string>${a}</string>`).join("\n");
286
+ return `<?xml version="1.0" encoding="UTF-8"?>
287
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
288
+ <plist version="1.0">
289
+ <dict>
290
+ <key>Label</key>
291
+ <string>${DAEMON_SERVICE_LABEL}</string>
292
+ <key>ProgramArguments</key>
293
+ <array>
294
+ ${args}
295
+ </array>
296
+ <key>RunAtLoad</key>
297
+ <true/>
298
+ <key>KeepAlive</key>
299
+ <false/>
300
+ <key>StandardOutPath</key>
301
+ <string>${opts.logPath}</string>
302
+ <key>StandardErrorPath</key>
303
+ <string>${opts.logPath}</string>
304
+ </dict>
305
+ </plist>
306
+ `;
307
+ }
308
+ function renderSystemdUnit(opts) {
309
+ const execStart = serviceArgv(opts.execPath, opts.scriptPath, opts.any).join(" ");
310
+ return `[Unit]
311
+ Description=vibedating notify-only daemon (alerts on new matches; never opens chat/video)
312
+
313
+ [Service]
314
+ ExecStart=${execStart}
315
+ Restart=on-failure
316
+ RestartSec=10
317
+
318
+ [Install]
319
+ WantedBy=default.target
320
+ `;
321
+ }
322
+ var defaultRun = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit" }).status === 0;
323
+ function installDaemonService(opts) {
324
+ const platform = opts.platform ?? process.platform;
325
+ const homeDir = opts.homeDir ?? os.homedir();
326
+ const run = opts.run ?? defaultRun;
327
+ const servicePath = daemonServicePath(platform, homeDir);
328
+ if (servicePath === null) {
329
+ return {
330
+ installed: false,
331
+ servicePath: null,
332
+ detail: `unsupported platform (${platform}) \u2014 run \`vibedate daemon start\` manually instead`
333
+ };
334
+ }
335
+ const execPath = opts.execPath ?? process.execPath;
336
+ const scriptPath = opts.scriptPath ?? process.argv[1];
337
+ if (scriptPath === void 0) {
338
+ return { installed: false, servicePath, detail: "cannot locate the CLI entry point" };
339
+ }
340
+ mkdirSync(path.dirname(servicePath), { recursive: true });
341
+ if (platform === "darwin") {
342
+ const logPath = daemonLogPath(opts.dir ?? defaultStateDir());
343
+ writeFileSync(servicePath, renderLaunchdPlist({ execPath, scriptPath, any: opts.any, logPath }), "utf8");
344
+ const uid = process.getuid?.() ?? 501;
345
+ run("launchctl", ["bootout", `gui/${uid}`, servicePath]);
346
+ if (!run("launchctl", ["bootstrap", `gui/${uid}`, servicePath])) {
347
+ return { installed: false, servicePath, detail: "launchctl bootstrap failed \u2014 service written but not loaded" };
348
+ }
349
+ return { installed: true, servicePath, detail: "launchd agent installed \u2014 the daemon starts on login" };
350
+ }
351
+ writeFileSync(servicePath, renderSystemdUnit({ execPath, scriptPath, any: opts.any }), "utf8");
352
+ run("systemctl", ["--user", "daemon-reload"]);
353
+ if (!run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT_NAME])) {
354
+ return { installed: false, servicePath, detail: "systemctl enable failed \u2014 unit written but not started" };
355
+ }
356
+ return { installed: true, servicePath, detail: "systemd --user service installed \u2014 the daemon starts on login" };
357
+ }
358
+ function uninstallDaemonService(opts) {
359
+ const platform = opts.platform ?? process.platform;
360
+ const homeDir = opts.homeDir ?? os.homedir();
361
+ const run = opts.run ?? defaultRun;
362
+ const servicePath = daemonServicePath(platform, homeDir);
363
+ if (servicePath === null) {
364
+ return {
365
+ installed: false,
366
+ servicePath: null,
367
+ detail: `unsupported platform (${platform})`
368
+ };
369
+ }
370
+ if (platform === "darwin") {
371
+ const uid = process.getuid?.() ?? 501;
372
+ run("launchctl", ["bootout", `gui/${uid}`, servicePath]);
373
+ } else {
374
+ run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT_NAME]);
375
+ run("systemctl", ["--user", "daemon-reload"]);
376
+ }
377
+ try {
378
+ rmSync(servicePath, { force: true });
379
+ } catch {
380
+ }
381
+ return { installed: false, servicePath, detail: "login service removed \u2014 the daemon no longer starts on login" };
382
+ }
383
+
40
384
  // src/pairing.ts
41
385
  function createPairing() {
42
386
  const queue = [];
@@ -110,6 +454,7 @@ function createPairing() {
110
454
 
111
455
  // src/server.ts
112
456
  import http from "http";
457
+ import { randomUUID } from "crypto";
113
458
  import { makeEvent, notify as vibeCoreNotify } from "@pooriaarab/vibe-core";
114
459
 
115
460
  // src/web-app-html.ts
@@ -545,6 +890,80 @@ var webAppHtml = `<!DOCTYPE html>
545
890
  background: var(--danger); color: #2a0a0c;
546
891
  }
547
892
  .video-modal .vhangup:hover{ filter: brightness(1.06); }
893
+ .live-panel .lp-legend{ font-size: .66rem; color: var(--muted-2); margin-top: 8px; line-height: 1.4; }
894
+ .incoming-call{
895
+ position: fixed; inset: 0; z-index: 65;
896
+ display: flex; align-items: center; justify-content: center;
897
+ background: rgba(12,7,15,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
898
+ opacity: 0; pointer-events: none; transition: opacity var(--dur-med) ease;
899
+ }
900
+ .incoming-call.is-open{ opacity: 1; pointer-events: auto; }
901
+ .incoming-call .ic-card{
902
+ background: linear-gradient(165deg, var(--bg-card), var(--bg-card-2));
903
+ border: 1px solid var(--border-2); border-radius: 24px; box-shadow: var(--shadow-3);
904
+ padding: 30px 28px; max-width: 320px; text-align: center;
905
+ transform: scale(.85) translateY(10px); transition: transform var(--dur-med) var(--ease-bounce);
906
+ }
907
+ .incoming-call.is-open .ic-card{ transform: scale(1) translateY(0); }
908
+ .incoming-call h3{ margin: 0 0 6px; font-size: 1.15rem; font-weight: 800; word-break: break-all; }
909
+ .incoming-call p{ color: var(--muted); font-size: .85rem; margin: 0 0 18px; }
910
+ .incoming-call .ic-actions{ display: flex; gap: 10px; }
911
+
912
+ /* ---- live text chat (browser <-> PeerLink over /live/message) ---- */
913
+ .live-actions{ display: flex; gap: 6px; flex-shrink: 0; }
914
+ .live-row .cbtn{
915
+ border: 1px solid var(--border-2); border-radius: 9px; padding: 7px 12px;
916
+ font-weight: 700; font-size: .76rem; background: transparent; color: var(--muted);
917
+ transition: color var(--dur-fast) ease, border-color var(--dur-fast) ease, transform var(--dur-fast) var(--ease-out);
918
+ flex-shrink: 0;
919
+ }
920
+ .live-row .cbtn:hover{ color: var(--fg); border-color: var(--muted-2); }
921
+ .live-row .cbtn:active{ transform: scale(.95); }
922
+ .live-row .cbtn.has-unread{ color: var(--coral); border-color: var(--coral); }
923
+ .chat-panel{
924
+ position: fixed; right: 18px; bottom: 18px; z-index: 45;
925
+ width: min(320px, calc(100vw - 36px));
926
+ background: linear-gradient(180deg, var(--bg-card), var(--bg-card-2));
927
+ border: 1px solid var(--border-2); border-radius: 16px; box-shadow: var(--shadow-2);
928
+ display: none; flex-direction: column; overflow: hidden;
929
+ }
930
+ .chat-panel.is-open{ display: flex; animation: rise var(--dur-med) var(--ease-out); }
931
+ .chat-panel .cp-head{
932
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
933
+ padding: 11px 14px; border-bottom: 1px solid var(--border);
934
+ }
935
+ .chat-panel .cp-title{ font-size: .84rem; font-weight: 700; word-break: break-all; }
936
+ .chat-panel .cp-sub{ font-size: .68rem; color: var(--muted-2); margin-top: 2px; }
937
+ .chat-panel .cp-close{
938
+ border: 0; background: transparent; color: var(--muted-2);
939
+ font-size: 1.15rem; line-height: 1; padding: 4px; flex-shrink: 0;
940
+ }
941
+ .chat-panel .cp-close:hover{ color: var(--fg); }
942
+ .chat-panel .cp-msgs{
943
+ padding: 12px 14px; min-height: 120px; max-height: 260px; overflow-y: auto;
944
+ display: flex; flex-direction: column; gap: 7px;
945
+ }
946
+ .chat-panel .cp-empty{ font-size: .76rem; color: var(--muted-2); text-align: center; margin: auto; }
947
+ .cp-msg{
948
+ max-width: 85%; padding: 7px 11px; border-radius: 13px;
949
+ font-size: .82rem; line-height: 1.4; word-break: break-word; white-space: pre-wrap;
950
+ }
951
+ .cp-msg.them{ align-self: flex-start; background: rgba(248,239,232,.07); border: 1px solid var(--border); }
952
+ .cp-msg.you{ align-self: flex-end; background: rgba(255,122,104,.16); border: 1px solid rgba(255,122,104,.32); }
953
+ .cp-msg.sys{ align-self: center; background: transparent; border: 0; color: var(--muted-2); font-size: .72rem; padding: 2px 6px; }
954
+ .chat-panel .cp-inputrow{ display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); }
955
+ .chat-panel .cp-inputrow input{
956
+ flex: 1; border: 1px solid var(--border-2); border-radius: 10px; background: rgba(0,0,0,.22);
957
+ color: var(--fg); padding: 9px 11px; font: inherit; font-size: .82rem; outline: none; min-width: 0;
958
+ }
959
+ .chat-panel .cp-inputrow input:focus{ border-color: var(--coral); }
960
+ .chat-panel .cp-send{
961
+ border: 0; border-radius: 10px; padding: 9px 14px; font-weight: 700; font-size: .8rem;
962
+ background: linear-gradient(180deg, var(--coral), var(--coral-dim)); color: #2a1109;
963
+ transition: filter var(--dur-fast) ease, transform var(--dur-fast) var(--ease-out);
964
+ }
965
+ .chat-panel .cp-send:hover{ filter: brightness(1.06); }
966
+ .chat-panel .cp-send:active{ transform: scale(.95); }
548
967
  </style>
549
968
  </head>
550
969
  <body>
@@ -689,9 +1108,36 @@ var webAppHtml = `<!DOCTYPE html>
689
1108
  </div>
690
1109
  </div>
691
1110
 
692
- <aside class="live-panel" id="livePanel" aria-label="Live same-league peers">
693
- <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live same-league peers</div>
1111
+ <aside class="live-panel" id="livePanel" aria-label="Live peers">
1112
+ <div class="lp-title"><span class="lp-dot" aria-hidden="true"></span> Live peers</div>
694
1113
  <div id="liveRows"></div>
1114
+ <div class="lp-legend">&#10003; usage verified &middot; &#128273; identity verified &mdash; call someone, or wait for a call</div>
1115
+ </aside>
1116
+
1117
+ <div class="incoming-call" id="incomingCall" role="dialog" aria-modal="true" aria-label="Incoming call">
1118
+ <div class="ic-card">
1119
+ <h3 id="incomingCaller">@peer</h3>
1120
+ <p>is calling you &mdash; video chat</p>
1121
+ <div class="ic-actions">
1122
+ <button class="btn btn-ghost" id="declineBtn" type="button">Decline</button>
1123
+ <button class="btn btn-primary" id="acceptBtn" type="button">Accept</button>
1124
+ </div>
1125
+ </div>
1126
+ </div>
1127
+
1128
+ <aside class="chat-panel" id="chatPanel" aria-label="Text chat">
1129
+ <div class="cp-head">
1130
+ <div>
1131
+ <div class="cp-title" id="chatTitle">@peer</div>
1132
+ <div class="cp-sub" id="chatSub">live over the P2P link &middot; text only</div>
1133
+ </div>
1134
+ <button class="cp-close" id="chatClose" type="button" aria-label="Close chat">&times;</button>
1135
+ </div>
1136
+ <div class="cp-msgs" id="chatMsgs"></div>
1137
+ <div class="cp-inputrow">
1138
+ <input id="chatInput" type="text" maxlength="4000" placeholder="message&hellip;" autocomplete="off">
1139
+ <button class="cp-send" id="chatSend" type="button">Send</button>
1140
+ </div>
695
1141
  </aside>
696
1142
 
697
1143
  <div class="video-modal" id="videoModal" role="dialog" aria-modal="true" aria-label="Video call">
@@ -998,9 +1444,15 @@ var webAppHtml = `<!DOCTYPE html>
998
1444
  var remoteVideo = document.getElementById("remoteVideo");
999
1445
  var remoteLabel = document.getElementById("remoteLabel");
1000
1446
  var hangupBtn = document.getElementById("hangupBtn");
1447
+ var incomingCallEl = document.getElementById("incomingCall");
1448
+ var incomingCaller = document.getElementById("incomingCaller");
1449
+ var acceptBtn = document.getElementById("acceptBtn");
1450
+ var declineBtn = document.getElementById("declineBtn");
1001
1451
 
1002
1452
  // One in-flight call's state. remoteHandle === null means idle.
1003
1453
  var rtc = { pc: null, localStream: null, remoteHandle: null, role: null };
1454
+ // An incoming offer awaiting the user's accept/decline (never auto-answered).
1455
+ var pendingOffer = null;
1004
1456
  var knownPeers = [];
1005
1457
  var idleIdx = 0;
1006
1458
 
@@ -1117,7 +1569,8 @@ var webAppHtml = `<!DOCTYPE html>
1117
1569
  }
1118
1570
 
1119
1571
  // Idle listener: when not in a call, watch known peers round-robin so an
1120
- // incoming offer (the peer clicked THEIR Video button) is noticed and answered.
1572
+ // incoming offer (the peer clicked THEIR Call button) is noticed. Offers are
1573
+ // never auto-answered \u2014 the caller is named and the user accepts or declines.
1121
1574
  async function idleLoop(){
1122
1575
  while (true) {
1123
1576
  if (rtc.remoteHandle || knownPeers.length === 0) { await sleep(1000); continue; }
@@ -1129,7 +1582,12 @@ var webAppHtml = `<!DOCTYPE html>
1129
1582
  var data = await res.json();
1130
1583
  var f = data && data.frame;
1131
1584
  if (f && f.t === "rtc-offer" && !rtc.pc && !rtc.remoteHandle) {
1132
- await answerIncomingOffer(peer.handle, f.sdp);
1585
+ // One prompt at a time: a re-offer from the SAME caller refreshes
1586
+ // the stored sdp; an offer from someone else while a prompt is up
1587
+ // is dropped (their call simply fails to connect).
1588
+ if (pendingOffer && pendingOffer.handle !== peer.handle) continue;
1589
+ pendingOffer = { handle: peer.handle, sdp: f.sdp };
1590
+ showIncomingPrompt(peer.handle);
1133
1591
  }
1134
1592
  }
1135
1593
  } catch(e){ /* abort/timeout \u2014 loop to next peer */ }
@@ -1137,6 +1595,22 @@ var webAppHtml = `<!DOCTYPE html>
1137
1595
  }
1138
1596
  idleLoop();
1139
1597
 
1598
+ function showIncomingPrompt(handle){
1599
+ incomingCaller.textContent = handle;
1600
+ incomingCallEl.classList.add("is-open");
1601
+ }
1602
+ function clearIncomingPrompt(){
1603
+ pendingOffer = null;
1604
+ incomingCallEl.classList.remove("is-open");
1605
+ }
1606
+ acceptBtn.addEventListener("click", function(){
1607
+ var p = pendingOffer;
1608
+ clearIncomingPrompt();
1609
+ if (!p) return;
1610
+ answerIncomingOffer(p.handle, p.sdp).catch(function(){ hangup(); });
1611
+ });
1612
+ declineBtn.addEventListener("click", clearIncomingPrompt);
1613
+
1140
1614
  function hangup(){
1141
1615
  try { if (rtc.pc) rtc.pc.close(); } catch(e){}
1142
1616
  rtc.pc = null;
@@ -1152,7 +1626,171 @@ var webAppHtml = `<!DOCTYPE html>
1152
1626
  }
1153
1627
  hangupBtn.addEventListener("click", hangup);
1154
1628
 
1155
- // Render the live-peers panel with a Video button each.
1629
+ /* ---- Live text chat: msg frames over the same PeerLinks, via /live/message ----
1630
+ * One long-poll loop per connected peer surfaces incoming texts immediately,
1631
+ * no matter which conversation is open. Outbound texts POST {handle, text};
1632
+ * the server re-validates through the frame allowlist and mints id/at. */
1633
+ var chatPanel = document.getElementById("chatPanel");
1634
+ var chatTitle = document.getElementById("chatTitle");
1635
+ var chatSub = document.getElementById("chatSub");
1636
+ var chatMsgs = document.getElementById("chatMsgs");
1637
+ var chatInput = document.getElementById("chatInput");
1638
+ var chatSend = document.getElementById("chatSend");
1639
+ var chatClose = document.getElementById("chatClose");
1640
+
1641
+ var chatWith = null; // handle of the open conversation (null = closed)
1642
+ var conversations = {}; // handle -> [{from:"you"|"them"|"sys", text}]
1643
+ var unread = {}; // handle -> count of unseen incoming messages
1644
+ var chatLoops = {}; // handle -> true while its poll loop is running
1645
+ var MAX_CHAT_KEPT = 200; // per-conversation local cap (mirrors the server)
1646
+
1647
+ function fetchMessage(handle, timeoutMs){
1648
+ var ctrl = new AbortController();
1649
+ var t = setTimeout(function(){ ctrl.abort(); }, timeoutMs);
1650
+ return fetch("/live/message?handle=" + encodeURIComponent(handle), { signal: ctrl.signal })
1651
+ .then(function(r){ clearTimeout(t); return r; })
1652
+ .catch(function(e){ clearTimeout(t); throw e; });
1653
+ }
1654
+
1655
+ function postChat(handle, text){
1656
+ return fetch("/live/message", {
1657
+ method: "POST",
1658
+ headers: { "content-type": "application/json" },
1659
+ body: JSON.stringify({ handle: handle, text: text })
1660
+ });
1661
+ }
1662
+
1663
+ function peerKnown(handle){
1664
+ for (var i = 0; i < knownPeers.length; i++){ if (knownPeers[i].handle === handle) return true; }
1665
+ return false;
1666
+ }
1667
+
1668
+ function pushChat(handle, entry){
1669
+ var conv = conversations[handle] || (conversations[handle] = []);
1670
+ conv.push(entry);
1671
+ if (conv.length > MAX_CHAT_KEPT) conv.splice(0, conv.length - MAX_CHAT_KEPT);
1672
+ }
1673
+
1674
+ // One long-poll loop per connected peer: started when the peer is first
1675
+ // seen, stopped when it vanishes (its mailbox on the server is gone).
1676
+ function ensureChatLoop(handle){
1677
+ if (chatLoops[handle]) return;
1678
+ chatLoops[handle] = true;
1679
+ (async function(){
1680
+ while (peerKnown(handle)) {
1681
+ var res;
1682
+ try { res = await fetchMessage(handle, 30000); }
1683
+ catch(e){ await sleep(1000); continue; }
1684
+ if (!res || res.status !== 200) { await sleep(1000); continue; }
1685
+ var data = await res.json();
1686
+ var m = data && data.message;
1687
+ if (!m) continue; // timed out empty
1688
+ pushChat(handle, { from: "them", text: m.text });
1689
+ if (chatWith === handle) {
1690
+ renderChat();
1691
+ } else {
1692
+ unread[handle] = (unread[handle] || 0) + 1;
1693
+ renderLiveRows(knownPeers);
1694
+ }
1695
+ }
1696
+ delete chatLoops[handle];
1697
+ })();
1698
+ }
1699
+
1700
+ function renderChat(){
1701
+ chatMsgs.innerHTML = "";
1702
+ var conv = (chatWith && conversations[chatWith]) || [];
1703
+ if (conv.length === 0) {
1704
+ var empty = document.createElement("div");
1705
+ empty.className = "cp-empty";
1706
+ empty.textContent = "No messages yet \u2014 say hi.";
1707
+ chatMsgs.appendChild(empty);
1708
+ return;
1709
+ }
1710
+ conv.forEach(function(m){
1711
+ var el = document.createElement("div");
1712
+ el.className = "cp-msg " + (m.from === "you" ? "you" : m.from === "sys" ? "sys" : "them");
1713
+ // textContent only \u2014 a peer's text is never parsed as HTML.
1714
+ el.textContent = m.text;
1715
+ chatMsgs.appendChild(el);
1716
+ });
1717
+ chatMsgs.scrollTop = chatMsgs.scrollHeight;
1718
+ }
1719
+
1720
+ function openChat(handle){
1721
+ chatWith = handle;
1722
+ delete unread[handle];
1723
+ chatTitle.textContent = handle;
1724
+ chatSub.textContent = peerKnown(handle)
1725
+ ? "live over the P2P link \xB7 text only"
1726
+ : "peer offline \xB7 messages will not deliver";
1727
+ chatPanel.classList.add("is-open");
1728
+ renderChat();
1729
+ renderLiveRows(knownPeers);
1730
+ chatInput.focus();
1731
+ }
1732
+ chatClose.addEventListener("click", function(){
1733
+ chatWith = null;
1734
+ chatPanel.classList.remove("is-open");
1735
+ });
1736
+
1737
+ function sendChat(){
1738
+ var target = chatWith;
1739
+ var text = chatInput.value.trim();
1740
+ if (!target || text === "") return;
1741
+ chatInput.value = "";
1742
+ postChat(target, text).then(function(res){
1743
+ // Honest failure \u2014 never show a message as sent when it wasn't.
1744
+ pushChat(target, res && res.status === 200
1745
+ ? { from: "you", text: text }
1746
+ : { from: "sys", text: "(not delivered \u2014 peer offline?)" });
1747
+ if (chatWith === target) renderChat();
1748
+ }).catch(function(){
1749
+ pushChat(target, { from: "sys", text: "(not delivered \u2014 server unreachable)" });
1750
+ if (chatWith === target) renderChat();
1751
+ });
1752
+ }
1753
+ chatSend.addEventListener("click", sendChat);
1754
+ chatInput.addEventListener("keydown", function(e){ if (e.key === "Enter") sendChat(); });
1755
+
1756
+ // Render the live-peers rows: one per connected peer with its verification
1757
+ // marks, a Chat button (opens the conversation), and a Call button that
1758
+ // rings THAT peer specifically.
1759
+ function renderLiveRows(peers){
1760
+ liveRows.innerHTML = "";
1761
+ peers.forEach(function(p){
1762
+ var row = document.createElement("div");
1763
+ row.className = "live-row";
1764
+ var who = document.createElement("div");
1765
+ // Marks from the peer's hello, shown only when present: \u2713 usage
1766
+ // verified (real local logs), \u{1F511} identity-verified (signed hello).
1767
+ var marks = (p.verified === true ? " \u2713" : "") + (p.identityVerified === true ? " \u{1F511}" : "");
1768
+ var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle + marks;
1769
+ var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
1770
+ who.appendChild(h); who.appendChild(s);
1771
+ var actions = document.createElement("div");
1772
+ actions.className = "live-actions";
1773
+ var chatBtn = document.createElement("button");
1774
+ chatBtn.className = "cbtn" + (unread[p.handle] ? " has-unread" : "");
1775
+ chatBtn.type = "button";
1776
+ chatBtn.textContent = "Chat" + (unread[p.handle] ? " (" + unread[p.handle] + ")" : "");
1777
+ chatBtn.addEventListener("click", function(){ openChat(p.handle); });
1778
+ var btn = document.createElement("button");
1779
+ btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Call";
1780
+ btn.addEventListener("click", function(){
1781
+ if (rtc.remoteHandle || pendingOffer) return; // already in a call / prompt
1782
+ btn.disabled = true;
1783
+ // DIRECTED call: the rtc-offer is relayed only to this peer's handle.
1784
+ startCallAsOfferer(p.handle).catch(function(){ hangup(); });
1785
+ });
1786
+ actions.appendChild(chatBtn);
1787
+ actions.appendChild(btn);
1788
+ row.appendChild(who);
1789
+ row.appendChild(actions);
1790
+ liveRows.appendChild(row);
1791
+ });
1792
+ }
1793
+
1156
1794
  async function refreshPeers(){
1157
1795
  try {
1158
1796
  var res = await fetch("/api/live/peers");
@@ -1160,26 +1798,15 @@ var webAppHtml = `<!DOCTYPE html>
1160
1798
  var data = await res.json();
1161
1799
  var peers = (data && data.peers) || [];
1162
1800
  knownPeers = peers;
1801
+ peers.forEach(function(p){ ensureChatLoop(p.handle); });
1802
+ if (chatWith) {
1803
+ chatSub.textContent = peerKnown(chatWith)
1804
+ ? "live over the P2P link \xB7 text only"
1805
+ : "peer offline \xB7 messages will not deliver";
1806
+ }
1163
1807
  if (peers.length === 0) { livePanel.classList.remove("is-open"); return; }
1164
1808
  livePanel.classList.add("is-open");
1165
- liveRows.innerHTML = "";
1166
- peers.forEach(function(p){
1167
- var row = document.createElement("div");
1168
- row.className = "live-row";
1169
- var who = document.createElement("div");
1170
- var h = document.createElement("div"); h.className = "h"; h.textContent = p.handle;
1171
- var s = document.createElement("div"); s.className = "s"; s.textContent = p.league + " \xB7 " + p.harness;
1172
- who.appendChild(h); who.appendChild(s);
1173
- var btn = document.createElement("button");
1174
- btn.className = "vbtn"; btn.type = "button"; btn.textContent = "Video";
1175
- btn.addEventListener("click", function(){
1176
- if (rtc.remoteHandle) return; // already in a call
1177
- btn.disabled = true;
1178
- startCallAsOfferer(p.handle).catch(function(){ btn.disabled = false; });
1179
- });
1180
- row.appendChild(who); row.appendChild(btn);
1181
- liveRows.appendChild(row);
1182
- });
1809
+ renderLiveRows(peers);
1183
1810
  } catch(e){}
1184
1811
  }
1185
1812
  refreshPeers();
@@ -1190,19 +1817,37 @@ var webAppHtml = `<!DOCTYPE html>
1190
1817
  </html>`;
1191
1818
 
1192
1819
  // src/server.ts
1820
+ var MAX_QUEUED_MESSAGES = 200;
1193
1821
  function createLiveBridge() {
1194
1822
  const boxes = /* @__PURE__ */ new Map();
1195
1823
  const bridge = {
1196
1824
  get peers() {
1197
- return [...boxes.values()].map((m) => m.link.hello);
1825
+ return [...boxes.values()].map((m) => {
1826
+ const h = m.link.hello;
1827
+ return {
1828
+ handle: h.handle,
1829
+ league: h.league,
1830
+ harness: h.harness,
1831
+ ...h.verified !== void 0 ? { verified: h.verified } : {},
1832
+ ...h.identityVerified !== void 0 ? { identityVerified: h.identityVerified } : {}
1833
+ };
1834
+ });
1198
1835
  },
1199
1836
  addLink(link) {
1200
1837
  const handle2 = link.hello.handle;
1201
- boxes.set(handle2, { link, incoming: [] });
1838
+ boxes.set(handle2, { link, incoming: [], messages: [] });
1202
1839
  link.onSignal((f) => {
1203
1840
  const mb = boxes.get(handle2);
1204
1841
  if (mb) mb.incoming.push(f);
1205
1842
  });
1843
+ link.onMessage((m) => {
1844
+ const mb = boxes.get(handle2);
1845
+ if (!mb) return;
1846
+ mb.messages.push({ ...m, text: sanitizePeerText(m.text) });
1847
+ if (mb.messages.length > MAX_QUEUED_MESSAGES) {
1848
+ mb.messages.splice(0, mb.messages.length - MAX_QUEUED_MESSAGES);
1849
+ }
1850
+ });
1206
1851
  link.onClose(() => {
1207
1852
  const cur = boxes.get(handle2);
1208
1853
  if (cur && cur.link === link) boxes.delete(handle2);
@@ -1223,6 +1868,22 @@ function createLiveBridge() {
1223
1868
  if (cur.incoming.length > 0) return cur.incoming.shift() ?? null;
1224
1869
  }
1225
1870
  return null;
1871
+ },
1872
+ sendMessage(handle2, text) {
1873
+ boxes.get(handle2)?.link.send(text);
1874
+ },
1875
+ async pollMessage(handle2, timeoutMs) {
1876
+ const mb = boxes.get(handle2);
1877
+ if (!mb) return null;
1878
+ if (mb.messages.length > 0) return mb.messages.shift() ?? null;
1879
+ const deadline = Date.now() + timeoutMs;
1880
+ while (Date.now() < deadline) {
1881
+ await new Promise((r) => setTimeout(r, 100));
1882
+ const cur = boxes.get(handle2);
1883
+ if (!cur) return null;
1884
+ if (cur.messages.length > 0) return cur.messages.shift() ?? null;
1885
+ }
1886
+ return null;
1226
1887
  }
1227
1888
  };
1228
1889
  return bridge;
@@ -1396,11 +2057,62 @@ async function handle(req, res, opts) {
1396
2057
  sendJson(res, 200, { ok: true });
1397
2058
  return;
1398
2059
  }
2060
+ if (req.method === "GET" && pathname === "/live/message") {
2061
+ const live = opts.live;
2062
+ if (!live) {
2063
+ sendJson(res, 200, { message: null, reason: "live-not-attached" });
2064
+ return;
2065
+ }
2066
+ const handle2 = url.searchParams.get("handle") ?? "";
2067
+ if (handle2 === "") {
2068
+ sendJson(res, 400, { error: "missing handle" });
2069
+ return;
2070
+ }
2071
+ const message = await live.pollMessage(handle2, 25e3);
2072
+ if (req.destroyed || res.writableEnded) return;
2073
+ sendJson(res, 200, { message });
2074
+ return;
2075
+ }
2076
+ if (req.method === "POST" && pathname === "/live/message") {
2077
+ const live = opts.live;
2078
+ if (!live) {
2079
+ sendJson(res, 400, { error: "live-not-attached" });
2080
+ return;
2081
+ }
2082
+ const body = await readBody(req);
2083
+ let parsed = {};
2084
+ try {
2085
+ parsed = JSON.parse(body);
2086
+ } catch {
2087
+ sendJson(res, 400, { error: "invalid JSON body" });
2088
+ return;
2089
+ }
2090
+ const handle2 = typeof parsed["handle"] === "string" ? parsed["handle"] : "";
2091
+ if (handle2 === "") {
2092
+ sendJson(res, 400, { error: "missing handle" });
2093
+ return;
2094
+ }
2095
+ const text = parsed["text"];
2096
+ if (typeof text !== "string") {
2097
+ sendJson(res, 400, { error: "missing text" });
2098
+ return;
2099
+ }
2100
+ const reParsed = parseFrame(
2101
+ JSON.stringify({ t: "msg", id: randomUUID(), text, at: Date.now() })
2102
+ );
2103
+ if (reParsed === null || reParsed.t !== "msg") {
2104
+ sendJson(res, 400, { error: "invalid message text" });
2105
+ return;
2106
+ }
2107
+ live.sendMessage(handle2, reParsed.text);
2108
+ sendJson(res, 200, { ok: true });
2109
+ return;
2110
+ }
1399
2111
  sendJson(res, 404, { error: "not found" });
1400
2112
  }
1401
2113
 
1402
2114
  // src/cli.ts
1403
- var VERSION = "0.4.0";
2115
+ var VERSION = "0.5.0";
1404
2116
  function parsePort(raw) {
1405
2117
  const n = Number(raw);
1406
2118
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -1414,7 +2126,8 @@ function parseArgs(argv) {
1414
2126
  dating: false,
1415
2127
  any: false,
1416
2128
  arg: void 0,
1417
- to: void 0
2129
+ to: void 0,
2130
+ keepAlive: false
1418
2131
  };
1419
2132
  for (let i = 0; i < argv.length; i++) {
1420
2133
  const a = argv[i];
@@ -1426,7 +2139,8 @@ function parseArgs(argv) {
1426
2139
  dating: false,
1427
2140
  any: false,
1428
2141
  arg: void 0,
1429
- to: void 0
2142
+ to: void 0,
2143
+ keepAlive: false
1430
2144
  };
1431
2145
  }
1432
2146
  if (a === "--help" || a === "-h") {
@@ -1437,13 +2151,18 @@ function parseArgs(argv) {
1437
2151
  dating: false,
1438
2152
  any: false,
1439
2153
  arg: void 0,
1440
- to: void 0
2154
+ to: void 0,
2155
+ keepAlive: false
1441
2156
  };
1442
2157
  }
1443
2158
  if (a === "--live") {
1444
2159
  out = { ...out, live: true };
1445
2160
  continue;
1446
2161
  }
2162
+ if (a === "--keep-alive") {
2163
+ out = { ...out, keepAlive: true };
2164
+ continue;
2165
+ }
1447
2166
  if (a === "--any") {
1448
2167
  out = { ...out, any: true };
1449
2168
  continue;
@@ -1479,7 +2198,7 @@ function parseArgs(argv) {
1479
2198
  continue;
1480
2199
  }
1481
2200
  if (a.startsWith("-")) continue;
1482
- const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "mcp" || a === "help" ? a : null;
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;
1483
2202
  if (known !== null && out.command === null) {
1484
2203
  out = { ...out, command: known };
1485
2204
  } else if (out.arg === void 0) {
@@ -1499,6 +2218,17 @@ function formatTokens(n) {
1499
2218
  if (abs >= 1e3) return `${trim(n / 1e3)}k`;
1500
2219
  return String(n);
1501
2220
  }
2221
+ function formatAgo(iso, now = /* @__PURE__ */ new Date()) {
2222
+ const t = Date.parse(iso);
2223
+ if (Number.isNaN(t)) return "unknown";
2224
+ const s = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
2225
+ if (s < 60) return "just now";
2226
+ const m = Math.floor(s / 60);
2227
+ if (m < 60) return `${m}m ago`;
2228
+ const h = Math.floor(m / 60);
2229
+ if (h < 24) return `${h}h ago`;
2230
+ return `${Math.floor(h / 24)}d ago`;
2231
+ }
1502
2232
  function verificationText(snapshot) {
1503
2233
  if (snapshot.source === "real") {
1504
2234
  return `verified: real usage \u2014 ${formatTokens(snapshot.totalTokens)} tokens from ${snapshot.harness} logs`;
@@ -1545,7 +2275,9 @@ function idMark(peer) {
1545
2275
  var MARKS_LEGEND = "marks: \u2713 usage verified (real local logs) \xB7 ~ unverified (self-report/demo/legacy) \xB7 \u{1F511} identity-verified (signed hello)";
1546
2276
  async function cmdConnect() {
1547
2277
  const harness = process2.env["VIBEDATING_HARNESS"] ?? "claude-code";
1548
- const handle2 = resolveHandle();
2278
+ const ensured = ensureHandle();
2279
+ const handle2 = ensured.handle;
2280
+ const firstConnect = loadProfile() === null;
1549
2281
  const snapshot = await readUsage(harness);
1550
2282
  const profile = connectProfile(snapshot, handle2);
1551
2283
  const identity = loadOrCreateIdentity();
@@ -1555,12 +2287,20 @@ async function cmdConnect() {
1555
2287
  `);
1556
2288
  process2.stdout.write(` handle: ${profile.handle} \xB7 harness: ${profile.harness}
1557
2289
  `);
2290
+ if (ensured.generated) {
2291
+ process2.stdout.write(` assigned handle: ${profile.handle} \u2014 change it with: vibedate handle @name
2292
+ `);
2293
+ }
1558
2294
  process2.stdout.write(` verification: ${verificationText(snapshot)}
1559
2295
  `);
1560
2296
  process2.stdout.write(` identity: ed25519 ${identity.publicKeyHex.slice(0, 12)}\u2026 \u2014 signs your hello (\u{1F511})
1561
2297
  `);
1562
2298
  process2.stdout.write("\n");
1563
- process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n\n");
2299
+ process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n");
2300
+ if (firstConnect) {
2301
+ process2.stdout.write(" \u2022 optional: get notified of new matches on login \u2014 `vibedate daemon install` (undo: daemon uninstall)\n");
2302
+ }
2303
+ process2.stdout.write("\n");
1564
2304
  return 0;
1565
2305
  }
1566
2306
  async function cmdHandle(arg) {
@@ -1613,7 +2353,8 @@ async function cmdMatches(live) {
1613
2353
 
1614
2354
  `);
1615
2355
  for (const p of livePeers) {
1616
- process2.stdout.write(` ${p.handle.padEnd(28)} ${p.league} \xB7 ${p.harness} ${usageMark(p)}${idMark(p)}
2356
+ const lastMsg = p.lastMessageAt !== void 0 ? ` \xB7 last msg ${formatAgo(p.lastMessageAt)}` : "";
2357
+ process2.stdout.write(` ${p.handle.padEnd(28)} ${p.league} \xB7 ${p.harness} ${usageMark(p)}${idMark(p)}${lastMsg}
1617
2358
  `);
1618
2359
  }
1619
2360
  process2.stdout.write("\n");
@@ -1698,7 +2439,7 @@ async function cmdDiscover(live, any) {
1698
2439
  );
1699
2440
  return 0;
1700
2441
  }
1701
- async function cmdOpen(port) {
2442
+ async function cmdOpen(port, any) {
1702
2443
  const profile = loadProfile();
1703
2444
  let live;
1704
2445
  let session;
@@ -1712,7 +2453,14 @@ async function cmdOpen(port) {
1712
2453
  ${LIVE_NOTICE}
1713
2454
  `);
1714
2455
  const hello = buildHello(profile);
1715
- void startDiscovery({ hello, isBlocked: blockedChecker(), onLink: (link) => live.addLink(link) }).then((s) => {
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) => {
1716
2464
  session = s;
1717
2465
  }).catch(() => {
1718
2466
  });
@@ -1721,9 +2469,11 @@ async function cmdOpen(port) {
1721
2469
  vibedating local web app \u2192 ${started.url}
1722
2470
  `);
1723
2471
  if (live) {
1724
- process2.stdout.write(" \u2022 live A/V (video) available for connected same-league peers\n");
2472
+ process2.stdout.write(
2473
+ 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
+ );
1725
2475
  } else {
1726
- process2.stdout.write(" \u2022 connect first (`vibedating connect`) to enable live video\n");
2476
+ process2.stdout.write(" \u2022 connect first (`vibedating connect`) to enable live video + chat\n");
1727
2477
  }
1728
2478
  process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n");
1729
2479
  process2.stdout.write(" (Ctrl+C to stop)\n\n");
@@ -1736,7 +2486,10 @@ async function cmdOpen(port) {
1736
2486
  await new Promise((resolve) => started.server.close(() => resolve()));
1737
2487
  return 0;
1738
2488
  }
1739
- async function cmdLive(dating, any, to) {
2489
+ function shouldKeepAlive(flag, stdinIsTTY) {
2490
+ return flag || stdinIsTTY !== true;
2491
+ }
2492
+ async function cmdLive(dating, any, to, keepAlive) {
1740
2493
  const profile = loadProfile();
1741
2494
  if (!profile) {
1742
2495
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -1771,11 +2524,11 @@ async function cmdLive(dating, any, to) {
1771
2524
  }
1772
2525
  const { qual } = peerDirection(profile.league, link.hello.league);
1773
2526
  process2.stdout.write(
1774
- ` \xB7 matched ${link.hello.handle} (${link.hello.league}${qual} \xB7 ${link.hello.harness}) ${usageMark(link.hello)}${idMark(link.hello)}
2527
+ ` \xB7 matched ${sanitizePeerText(link.hello.handle)} (${link.hello.league}${qual} \xB7 ${link.hello.harness}) ${usageMark(link.hello)}${idMark(link.hello)}
1775
2528
  `
1776
2529
  );
1777
2530
  link.onMessage((m) => {
1778
- process2.stdout.write(` <${link.hello.handle}> ${m.text}
2531
+ process2.stdout.write(` <${sanitizePeerText(link.hello.handle)}> ${sanitizePeerText(m.text)}
1779
2532
  `);
1780
2533
  });
1781
2534
  });
@@ -1789,14 +2542,14 @@ async function cmdLive(dating, any, to) {
1789
2542
  if (target !== null && !sameHandle(link.hello.handle, target)) {
1790
2543
  const { qual } = peerDirection(profile.league, link.hello.league);
1791
2544
  process2.stdout.write(
1792
- ` + ${link.hello.handle} (${link.hello.league}${qual}) ${usageMark(link.hello)}${idMark(link.hello)} \u2014 not your target
2545
+ ` + ${sanitizePeerText(link.hello.handle)} (${link.hello.league}${qual}) ${usageMark(link.hello)}${idMark(link.hello)} \u2014 not your target
1793
2546
  `
1794
2547
  );
1795
2548
  link.close();
1796
2549
  return;
1797
2550
  }
1798
2551
  if (target !== null) {
1799
- process2.stdout.write(` \u2605 found ${link.hello.handle} \u2014 auto-opening
2552
+ process2.stdout.write(` \u2605 found ${sanitizePeerText(link.hello.handle)} \u2014 auto-opening
1800
2553
  `);
1801
2554
  }
1802
2555
  pairing.add(link);
@@ -1814,9 +2567,13 @@ async function cmdLive(dating, any, to) {
1814
2567
  };
1815
2568
  process2.once("SIGINT", stop);
1816
2569
  process2.once("SIGTERM", stop);
2570
+ let quitRequested = false;
1817
2571
  for await (const line of rl) {
1818
2572
  const text = line.trim();
1819
- if (text === "/quit") break;
2573
+ if (text === "/quit") {
2574
+ quitRequested = true;
2575
+ break;
2576
+ }
1820
2577
  if (text === "/next") {
1821
2578
  pairing.next();
1822
2579
  continue;
@@ -1837,6 +2594,13 @@ async function cmdLive(dating, any, to) {
1837
2594
  process2.stdout.write(" \xB7 no peer yet \u2014 waiting for a match\u2026\n");
1838
2595
  }
1839
2596
  }
2597
+ if (!quitRequested && shouldKeepAlive(keepAlive, process2.stdin.isTTY)) {
2598
+ process2.stdout.write(" stdin closed \u2014 staying in the swarm (Ctrl+C / SIGTERM to leave)\n");
2599
+ await new Promise((resolve) => {
2600
+ process2.once("SIGINT", () => resolve());
2601
+ process2.once("SIGTERM", () => resolve());
2602
+ });
2603
+ }
1840
2604
  process2.removeListener("SIGINT", stop);
1841
2605
  process2.removeListener("SIGTERM", stop);
1842
2606
  const cur = pairing.current();
@@ -1974,20 +2738,131 @@ async function cmdBlocklist() {
1974
2738
  `);
1975
2739
  return 0;
1976
2740
  }
2741
+ async function cmdDaemonRun(any) {
2742
+ const profile = loadProfile();
2743
+ if (!profile) {
2744
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
2745
+ return 1;
2746
+ }
2747
+ if (!canShareLive()) grantLiveConsent();
2748
+ const dir = defaultStateDir();
2749
+ writeDaemonState(dir, { pid: process2.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), any, version: VERSION });
2750
+ const hello = buildHello(profile);
2751
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
2752
+ const session = await startDiscovery({
2753
+ hello,
2754
+ topics,
2755
+ acceptLeague,
2756
+ isBlocked: blockedChecker(),
2757
+ onPeer: (peer, isNew) => {
2758
+ process2.stdout.write(
2759
+ ` [${(/* @__PURE__ */ new Date()).toISOString()}] ${isNew ? "NEW match" : "peer seen"}: ${sanitizePeerText(peer.handle)} (${peer.league} \xB7 ${peer.harness})
2760
+ `
2761
+ );
2762
+ }
2763
+ });
2764
+ process2.stdout.write(
2765
+ ` vibedating daemon running (pid ${process2.pid}) \u2014 notify-only${any ? " \xB7 --any" : ""}
2766
+ `
2767
+ );
2768
+ await new Promise((resolve) => {
2769
+ process2.once("SIGINT", () => resolve());
2770
+ process2.once("SIGTERM", () => resolve());
2771
+ });
2772
+ await session.close();
2773
+ removeDaemonState(dir);
2774
+ process2.stdout.write(" daemon stopped\n");
2775
+ return 0;
2776
+ }
2777
+ async function cmdDaemon(arg, any) {
2778
+ switch (arg) {
2779
+ case "start": {
2780
+ if (loadProfile() === null) {
2781
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
2782
+ return 1;
2783
+ }
2784
+ const r = startDaemon({ any, version: VERSION });
2785
+ if (!r.started) {
2786
+ process2.stdout.write(` ${r.reason}
2787
+ `);
2788
+ return 0;
2789
+ }
2790
+ process2.stdout.write(` daemon started (pid ${r.pid}) \u2014 notify-only: alerts on NEW matches, never opens chat/video
2791
+ `);
2792
+ process2.stdout.write(` logs: ~/.vibedating/daemon.log \xB7 stop: vibedate daemon stop
2793
+ `);
2794
+ return 0;
2795
+ }
2796
+ case "stop": {
2797
+ const r = await stopDaemon();
2798
+ process2.stdout.write(
2799
+ r.stopped ? ` daemon stopped (pid ${r.pid})
2800
+ ` : ` ${r.reason}
2801
+ `
2802
+ );
2803
+ return 0;
2804
+ }
2805
+ case "status": {
2806
+ const s = daemonStatus();
2807
+ if (s.running && s.state !== null) {
2808
+ process2.stdout.write(
2809
+ ` daemon running (pid ${s.state.pid}) since ${s.state.startedAt}${s.state.any ? " \xB7 --any" : ""}
2810
+ `
2811
+ );
2812
+ } else {
2813
+ process2.stdout.write(" daemon not running\n");
2814
+ }
2815
+ return 0;
2816
+ }
2817
+ case "run":
2818
+ return cmdDaemonRun(any);
2819
+ case "install": {
2820
+ if (loadProfile() === null) {
2821
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
2822
+ return 1;
2823
+ }
2824
+ const r = installDaemonService({ any });
2825
+ process2.stdout.write(` ${r.detail}
2826
+ `);
2827
+ if (r.servicePath !== null) process2.stdout.write(` service: ${r.servicePath}
2828
+ `);
2829
+ if (r.installed) process2.stdout.write(" undo anytime: vibedate daemon uninstall\n");
2830
+ return r.installed ? 0 : 1;
2831
+ }
2832
+ case "uninstall": {
2833
+ const r = uninstallDaemonService({});
2834
+ process2.stdout.write(` ${r.detail}
2835
+ `);
2836
+ return 0;
2837
+ }
2838
+ default:
2839
+ process2.stderr.write("usage: vibedating daemon [start|stop|status|install|uninstall] [--any]\n");
2840
+ return 1;
2841
+ }
2842
+ }
1977
2843
  var HELP = `vibedating ${VERSION} \u2014 dating by tokens (local-first)
1978
2844
 
1979
2845
  Usage:
1980
2846
  vibedating connect Read your usage, compute + print your league
1981
2847
  vibedating matches [--live] List candidates in your league (live peers if any)
1982
2848
  vibedating discover [--live] [--any] Find live peers over the DHT (your league + adjacent; --any = everyone)
1983
- vibedating live [--dating] [--any] [--to @handle] Live chat (your league + adjacent; --any = everyone; /next or --dating pick; --to targets one peer)
2849
+ vibedating live [--dating] [--any] [--to @handle] [--keep-alive] Live chat (your league
2850
+ + adjacent; --any = everyone; /next or --dating pick;
2851
+ --to targets one peer; --keep-alive survives stdin EOF \u2014
2852
+ automatic when stdin is not a TTY)
1984
2853
  vibedating find <@handle> [--any] Search the DHT for one specific handle (\u2605 highlights a match)
1985
2854
  vibedating handle [@name] Print or set your handle (persisted; a leading '@' is optional)
1986
2855
  vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
1987
2856
  vibedating unblock <@handle> Remove a handle from the blocklist
1988
2857
  vibedating blocklist List blocked handles
1989
- vibedating open [--port N] Serve the local web app (default: random port)
1990
- + live A/V video with connected same-league peers
2858
+ vibedating daemon [start|stop|status|install|uninstall] [--any]
2859
+ Manage the notify-only background daemon \u2014 alerts on
2860
+ NEW matches, never opens chat/video. install adds a
2861
+ login service (launchd on macOS, systemd on Linux);
2862
+ 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)
1991
2866
  vibedating mcp Run the stdio MCP server (profile, matches)
1992
2867
  vibedating --version
1993
2868
  vibedating --help
@@ -2001,9 +2876,10 @@ Privacy:
2001
2876
  (real local logs) \xB7 ~ unverified \xB7 \u{1F511} identity-verified (signed hello).
2002
2877
 
2003
2878
  Matching:
2004
- discover/live match your league + adjacent (\xB11) tiers by default, so thin
2005
- leagues and cross-league friends still connect. --any matches every league.
2006
- find / live --to look for one specific handle instead of the first random peer.
2879
+ discover/live/open match your league + adjacent (\xB11) tiers by default, so
2880
+ thin leagues and cross-league friends still connect. --any matches every
2881
+ league. find / live --to look for one specific handle instead of the first
2882
+ random peer.
2007
2883
 
2008
2884
  Env:
2009
2885
  VIBEDATING_TOKENS=<n> Self-report a token count (e.g. 23400000 or 12M)
@@ -2031,14 +2907,16 @@ async function main(argv) {
2031
2907
  return cmdUnblock(parsed.arg);
2032
2908
  case "blocklist":
2033
2909
  return cmdBlocklist();
2910
+ case "daemon":
2911
+ return cmdDaemon(parsed.arg, parsed.any);
2034
2912
  case "matches":
2035
2913
  return cmdMatches(parsed.live);
2036
2914
  case "discover":
2037
2915
  return cmdDiscover(parsed.live, parsed.any);
2038
2916
  case "open":
2039
- return cmdOpen(parsed.port);
2917
+ return cmdOpen(parsed.port, parsed.any);
2040
2918
  case "live":
2041
- return cmdLive(parsed.dating, parsed.any, parsed.to);
2919
+ return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive);
2042
2920
  case "find":
2043
2921
  return cmdFind(parsed.arg, parsed.any);
2044
2922
  case "mcp":
@@ -2069,5 +2947,7 @@ if (entryUrl !== void 0) {
2069
2947
  }
2070
2948
  }
2071
2949
  export {
2072
- parseArgs
2950
+ formatAgo,
2951
+ parseArgs,
2952
+ shouldKeepAlive
2073
2953
  };