vibedate 0.4.1 → 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/README.md CHANGED
@@ -9,6 +9,12 @@ Part of the **Vibe Suite** — companion tools for agentic coding CLIs. Ships as
9
9
  **CLI + npm package + MCP server**, plus a local web app. Built on
10
10
  [`@pooriaarab/vibe-core`](https://www.npmjs.com/package/@pooriaarab/vibe-core).
11
11
 
12
+ ## Demo
13
+
14
+ [▶ Watch the launch video](branding/launch-video.mp4) — she matched with the man of her dreams. He was coding in a basement.
15
+
16
+ https://github.com/pooriaarab/vibedating/raw/main/branding/launch-video.mp4
17
+
12
18
  > **Local-first.** Raw token usage is read and stored on your own machine and
13
19
  > **never leaves it.** Only the coarse league *bucket* is ever shared — never the
14
20
  > raw number, never per-project breakdowns. Live matching is peer-to-peer over a
@@ -62,8 +68,15 @@ VIBEDATING_TOKENS=23400000 vibedating connect # also accepts 12M / 1.2B / 500k
62
68
 
63
69
  ```
64
70
  vibedating connect Read usage, compute + print your league
65
- vibedating matches [--live] List candidates in your league (live peers if any)
71
+ (first run auto-assigns a memetic handle never @you)
72
+ vibedating matches [--live] List candidates in your league (live peers if any,
73
+ with last-message time)
66
74
  vibedating discover [--live] Find live same-league peers over the DHT (opt-in)
75
+ vibedating live [--keep-alive] Live text chat (--keep-alive / non-TTY survives stdin EOF)
76
+ vibedating handle [@name] Print or set your handle
77
+ vibedating daemon [start|stop|status|install|uninstall]
78
+ Notify-only background daemon — alerts on NEW matches,
79
+ never opens chat/video; install = run on login (launchd/systemd)
67
80
  vibedating open [--port N] Serve the local web app (default: random free port)
68
81
  vibedating mcp Run the stdio MCP server (tools: profile, matches)
69
82
  vibedating --version
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-43IYNWES.js";
5
+ } from "./chunk-KKWP4DLY.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -656,6 +656,14 @@ function createPeerLink(socket, hello, initialBuffer = "", linkOpts = {}) {
656
656
  };
657
657
  }
658
658
 
659
+ // src/untrusted.ts
660
+ var MAX_DISPLAY_TEXT_LEN = MAX_TEXT_LEN;
661
+ var UNSAFE_DISPLAY_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
662
+ function sanitizePeerText(text, maxLen = MAX_DISPLAY_TEXT_LEN) {
663
+ const cleaned = text.replace(UNSAFE_DISPLAY_CHARS, "");
664
+ return cleaned.length > maxLen ? cleaned.slice(0, maxLen) : cleaned;
665
+ }
666
+
659
667
  // src/p2p.ts
660
668
  var TOPIC_PREFIX = "vibedate:";
661
669
  function leagueTopic(leagueName) {
@@ -838,7 +846,9 @@ async function startDiscovery(opts) {
838
846
  try {
839
847
  notify(
840
848
  makeEvent("match", hello.harness, process.cwd(), {
841
- summary: `matched with ${peer.handle} - LIVE SAME LEAGUE`,
849
+ // AEGIS-lite: the handle is untrusted wire data — sanitized for
850
+ // display (the structured `handle` field below stays verbatim).
851
+ summary: `matched with ${sanitizePeerText(peer.handle)} - LIVE SAME LEAGUE`,
842
852
  handle: peer.handle,
843
853
  league: peer.league,
844
854
  harness: peer.harness
@@ -1036,12 +1046,16 @@ function matches(myLeague, candidates = CANDIDATES) {
1036
1046
 
1037
1047
  export {
1038
1048
  parseFrame,
1049
+ defaultStateDir,
1039
1050
  connectProfile,
1040
1051
  loadProfile,
1041
1052
  grantLiveConsent,
1042
1053
  canShareLive,
1054
+ DEFAULT_HANDLE,
1055
+ MAX_HANDLE_LEN,
1043
1056
  sameHandle,
1044
1057
  normalizeHandle,
1058
+ loadHandle,
1045
1059
  saveHandle,
1046
1060
  resolveHandle,
1047
1061
  loadBlocklist,
@@ -1053,6 +1067,7 @@ export {
1053
1067
  signHelloClaims,
1054
1068
  verifyHelloClaims,
1055
1069
  classifyHelloIdentity,
1070
+ sanitizePeerText,
1056
1071
  TOPIC_PREFIX,
1057
1072
  leagueTopic,
1058
1073
  LIVE_NOTICE,
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' | 'mcp' | 'help' | 'version' | null;
3
+ type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | 'mcp' | 'help' | 'version' | null;
4
4
  interface ParsedArgs {
5
5
  readonly command: Command;
6
6
  /** Port for `open --port`; undefined means "let the OS pick". */
@@ -15,11 +15,26 @@ interface ParsedArgs {
15
15
  readonly arg: string | undefined;
16
16
  /** `live --to <@handle>`: targeted match — auto-open that specific peer. */
17
17
  readonly to: string | undefined;
18
+ /** `live --keep-alive`: stay in the swarm after stdin EOF. Default false. */
19
+ readonly keepAlive: boolean;
18
20
  }
19
21
  /**
20
22
  * Parse argv (the slice AFTER the program name) into a command + options.
21
23
  * Pure: no IO, no process access — trivially unit-testable.
22
24
  */
23
25
  declare function parseArgs(argv: readonly string[]): ParsedArgs;
26
+ /**
27
+ * Compact relative time for local lists: an ISO timestamp → "just now" /
28
+ * "5m ago" / "3h ago" / "2d ago". Unparseable input → "unknown". Pure.
29
+ */
30
+ declare function formatAgo(iso: string, now?: Date): string;
31
+ /**
32
+ * Whether `live` should stay in the swarm after stdin closes. An explicit
33
+ * `--keep-alive` always keeps it; otherwise AUTO-detect: a non-TTY stdin
34
+ * (piped, `</dev/null`, backgrounded) hits EOF immediately, and exiting there
35
+ * would kill an unattended session — so it stays up until SIGINT/SIGTERM.
36
+ * Interactive TTY behavior is unchanged: Ctrl+D / EOF still exits.
37
+ */
38
+ declare function shouldKeepAlive(flag: boolean, stdinIsTTY: boolean | undefined): boolean;
24
39
 
25
- export { type Command, type ParsedArgs, parseArgs };
40
+ export { type Command, type ParsedArgs, formatAgo, parseArgs, shouldKeepAlive };
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 = [];
@@ -1499,7 +1843,7 @@ function createLiveBridge() {
1499
1843
  link.onMessage((m) => {
1500
1844
  const mb = boxes.get(handle2);
1501
1845
  if (!mb) return;
1502
- mb.messages.push(m);
1846
+ mb.messages.push({ ...m, text: sanitizePeerText(m.text) });
1503
1847
  if (mb.messages.length > MAX_QUEUED_MESSAGES) {
1504
1848
  mb.messages.splice(0, mb.messages.length - MAX_QUEUED_MESSAGES);
1505
1849
  }
@@ -1768,7 +2112,7 @@ async function handle(req, res, opts) {
1768
2112
  }
1769
2113
 
1770
2114
  // src/cli.ts
1771
- var VERSION = "0.4.1";
2115
+ var VERSION = "0.5.0";
1772
2116
  function parsePort(raw) {
1773
2117
  const n = Number(raw);
1774
2118
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
@@ -1782,7 +2126,8 @@ function parseArgs(argv) {
1782
2126
  dating: false,
1783
2127
  any: false,
1784
2128
  arg: void 0,
1785
- to: void 0
2129
+ to: void 0,
2130
+ keepAlive: false
1786
2131
  };
1787
2132
  for (let i = 0; i < argv.length; i++) {
1788
2133
  const a = argv[i];
@@ -1794,7 +2139,8 @@ function parseArgs(argv) {
1794
2139
  dating: false,
1795
2140
  any: false,
1796
2141
  arg: void 0,
1797
- to: void 0
2142
+ to: void 0,
2143
+ keepAlive: false
1798
2144
  };
1799
2145
  }
1800
2146
  if (a === "--help" || a === "-h") {
@@ -1805,13 +2151,18 @@ function parseArgs(argv) {
1805
2151
  dating: false,
1806
2152
  any: false,
1807
2153
  arg: void 0,
1808
- to: void 0
2154
+ to: void 0,
2155
+ keepAlive: false
1809
2156
  };
1810
2157
  }
1811
2158
  if (a === "--live") {
1812
2159
  out = { ...out, live: true };
1813
2160
  continue;
1814
2161
  }
2162
+ if (a === "--keep-alive") {
2163
+ out = { ...out, keepAlive: true };
2164
+ continue;
2165
+ }
1815
2166
  if (a === "--any") {
1816
2167
  out = { ...out, any: true };
1817
2168
  continue;
@@ -1847,7 +2198,7 @@ function parseArgs(argv) {
1847
2198
  continue;
1848
2199
  }
1849
2200
  if (a.startsWith("-")) continue;
1850
- 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;
1851
2202
  if (known !== null && out.command === null) {
1852
2203
  out = { ...out, command: known };
1853
2204
  } else if (out.arg === void 0) {
@@ -1867,6 +2218,17 @@ function formatTokens(n) {
1867
2218
  if (abs >= 1e3) return `${trim(n / 1e3)}k`;
1868
2219
  return String(n);
1869
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
+ }
1870
2232
  function verificationText(snapshot) {
1871
2233
  if (snapshot.source === "real") {
1872
2234
  return `verified: real usage \u2014 ${formatTokens(snapshot.totalTokens)} tokens from ${snapshot.harness} logs`;
@@ -1913,7 +2275,9 @@ function idMark(peer) {
1913
2275
  var MARKS_LEGEND = "marks: \u2713 usage verified (real local logs) \xB7 ~ unverified (self-report/demo/legacy) \xB7 \u{1F511} identity-verified (signed hello)";
1914
2276
  async function cmdConnect() {
1915
2277
  const harness = process2.env["VIBEDATING_HARNESS"] ?? "claude-code";
1916
- const handle2 = resolveHandle();
2278
+ const ensured = ensureHandle();
2279
+ const handle2 = ensured.handle;
2280
+ const firstConnect = loadProfile() === null;
1917
2281
  const snapshot = await readUsage(harness);
1918
2282
  const profile = connectProfile(snapshot, handle2);
1919
2283
  const identity = loadOrCreateIdentity();
@@ -1923,12 +2287,20 @@ async function cmdConnect() {
1923
2287
  `);
1924
2288
  process2.stdout.write(` handle: ${profile.handle} \xB7 harness: ${profile.harness}
1925
2289
  `);
2290
+ if (ensured.generated) {
2291
+ process2.stdout.write(` assigned handle: ${profile.handle} \u2014 change it with: vibedate handle @name
2292
+ `);
2293
+ }
1926
2294
  process2.stdout.write(` verification: ${verificationText(snapshot)}
1927
2295
  `);
1928
2296
  process2.stdout.write(` identity: ed25519 ${identity.publicKeyHex.slice(0, 12)}\u2026 \u2014 signs your hello (\u{1F511})
1929
2297
  `);
1930
2298
  process2.stdout.write("\n");
1931
- 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");
1932
2304
  return 0;
1933
2305
  }
1934
2306
  async function cmdHandle(arg) {
@@ -1981,7 +2353,8 @@ async function cmdMatches(live) {
1981
2353
 
1982
2354
  `);
1983
2355
  for (const p of livePeers) {
1984
- 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}
1985
2358
  `);
1986
2359
  }
1987
2360
  process2.stdout.write("\n");
@@ -2113,7 +2486,10 @@ async function cmdOpen(port, any) {
2113
2486
  await new Promise((resolve) => started.server.close(() => resolve()));
2114
2487
  return 0;
2115
2488
  }
2116
- 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) {
2117
2493
  const profile = loadProfile();
2118
2494
  if (!profile) {
2119
2495
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -2148,11 +2524,11 @@ async function cmdLive(dating, any, to) {
2148
2524
  }
2149
2525
  const { qual } = peerDirection(profile.league, link.hello.league);
2150
2526
  process2.stdout.write(
2151
- ` \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)}
2152
2528
  `
2153
2529
  );
2154
2530
  link.onMessage((m) => {
2155
- process2.stdout.write(` <${link.hello.handle}> ${m.text}
2531
+ process2.stdout.write(` <${sanitizePeerText(link.hello.handle)}> ${sanitizePeerText(m.text)}
2156
2532
  `);
2157
2533
  });
2158
2534
  });
@@ -2166,14 +2542,14 @@ async function cmdLive(dating, any, to) {
2166
2542
  if (target !== null && !sameHandle(link.hello.handle, target)) {
2167
2543
  const { qual } = peerDirection(profile.league, link.hello.league);
2168
2544
  process2.stdout.write(
2169
- ` + ${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
2170
2546
  `
2171
2547
  );
2172
2548
  link.close();
2173
2549
  return;
2174
2550
  }
2175
2551
  if (target !== null) {
2176
- process2.stdout.write(` \u2605 found ${link.hello.handle} \u2014 auto-opening
2552
+ process2.stdout.write(` \u2605 found ${sanitizePeerText(link.hello.handle)} \u2014 auto-opening
2177
2553
  `);
2178
2554
  }
2179
2555
  pairing.add(link);
@@ -2191,9 +2567,13 @@ async function cmdLive(dating, any, to) {
2191
2567
  };
2192
2568
  process2.once("SIGINT", stop);
2193
2569
  process2.once("SIGTERM", stop);
2570
+ let quitRequested = false;
2194
2571
  for await (const line of rl) {
2195
2572
  const text = line.trim();
2196
- if (text === "/quit") break;
2573
+ if (text === "/quit") {
2574
+ quitRequested = true;
2575
+ break;
2576
+ }
2197
2577
  if (text === "/next") {
2198
2578
  pairing.next();
2199
2579
  continue;
@@ -2214,6 +2594,13 @@ async function cmdLive(dating, any, to) {
2214
2594
  process2.stdout.write(" \xB7 no peer yet \u2014 waiting for a match\u2026\n");
2215
2595
  }
2216
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
+ }
2217
2604
  process2.removeListener("SIGINT", stop);
2218
2605
  process2.removeListener("SIGTERM", stop);
2219
2606
  const cur = pairing.current();
@@ -2351,18 +2738,128 @@ async function cmdBlocklist() {
2351
2738
  `);
2352
2739
  return 0;
2353
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
+ }
2354
2843
  var HELP = `vibedating ${VERSION} \u2014 dating by tokens (local-first)
2355
2844
 
2356
2845
  Usage:
2357
2846
  vibedating connect Read your usage, compute + print your league
2358
2847
  vibedating matches [--live] List candidates in your league (live peers if any)
2359
2848
  vibedating discover [--live] [--any] Find live peers over the DHT (your league + adjacent; --any = everyone)
2360
- 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)
2361
2853
  vibedating find <@handle> [--any] Search the DHT for one specific handle (\u2605 highlights a match)
2362
2854
  vibedating handle [@name] Print or set your handle (persisted; a leading '@' is optional)
2363
2855
  vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
2364
2856
  vibedating unblock <@handle> Remove a handle from the blocklist
2365
2857
  vibedating blocklist List blocked handles
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.
2366
2863
  vibedating open [--port N] [--any] Serve the local web app (default: random port)
2367
2864
  + live video + chat with connected peers
2368
2865
  (your league + adjacent; --any = every league)
@@ -2410,6 +2907,8 @@ async function main(argv) {
2410
2907
  return cmdUnblock(parsed.arg);
2411
2908
  case "blocklist":
2412
2909
  return cmdBlocklist();
2910
+ case "daemon":
2911
+ return cmdDaemon(parsed.arg, parsed.any);
2413
2912
  case "matches":
2414
2913
  return cmdMatches(parsed.live);
2415
2914
  case "discover":
@@ -2417,7 +2916,7 @@ async function main(argv) {
2417
2916
  case "open":
2418
2917
  return cmdOpen(parsed.port, parsed.any);
2419
2918
  case "live":
2420
- return cmdLive(parsed.dating, parsed.any, parsed.to);
2919
+ return cmdLive(parsed.dating, parsed.any, parsed.to, parsed.keepAlive);
2421
2920
  case "find":
2422
2921
  return cmdFind(parsed.arg, parsed.any);
2423
2922
  case "mcp":
@@ -2448,5 +2947,7 @@ if (entryUrl !== void 0) {
2448
2947
  }
2449
2948
  }
2450
2949
  export {
2451
- parseArgs
2950
+ formatAgo,
2951
+ parseArgs,
2952
+ shouldKeepAlive
2452
2953
  };
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  signHelloClaims,
27
27
  startDiscovery,
28
28
  verifyHelloClaims
29
- } from "./chunk-43IYNWES.js";
29
+ } from "./chunk-KKWP4DLY.js";
30
30
  export {
31
31
  BELOW_LEAGUE,
32
32
  CANDIDATES,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  runMcp
3
- } from "./chunk-ZB56ZBAR.js";
4
- import "./chunk-43IYNWES.js";
3
+ } from "./chunk-I5U3O4RH.js";
4
+ import "./chunk-KKWP4DLY.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.4.1",
3
+ "version": "0.5.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",