slashvibe-mcp 0.8.1 → 0.8.2

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/auth-store.js CHANGED
@@ -25,6 +25,25 @@ let _hydrated = false;
25
25
  * Hydrate auth state from disk (call once at MCP startup)
26
26
  * This loads persisted state from config.json
27
27
  */
28
+ /**
29
+ * The handle a token was ISSUED FOR, read from its own subject claim.
30
+ *
31
+ * Decode, not verify — the signature is the server's business and it checks it on
32
+ * every call. What this answers is narrower and local: "whose credential is this?"
33
+ * Returns null for anything unparseable, so a malformed token can never masquerade.
34
+ */
35
+ function handleFromToken(token) {
36
+ try {
37
+ const part = String(token).split('.')[1];
38
+ if (!part) return null;
39
+ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
40
+ const who = claims.handle || claims.sub;
41
+ return typeof who === 'string' && who ? who : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
28
47
  function hydrate() {
29
48
  if (_hydrated) return; // Only hydrate once
30
49
 
@@ -33,16 +52,35 @@ function hydrate() {
33
52
  const cfg = config.load();
34
53
 
35
54
  _token = cfg.authToken || cfg.privyToken || null;
36
- _handle = cfg.handle || cfg.username || null;
37
55
  _oneLiner = cfg.one_liner || cfg.workingOn || null;
38
56
 
39
57
  // Cross-client: if no token from config.json, check terminal/buddy auth.json
40
58
  if (!_token) {
41
59
  const terminalAuth = config.loadTerminalAuth();
42
- if (terminalAuth?.token) {
43
- _token = terminalAuth.token;
44
- _handle = _handle || terminalAuth.handle || null;
45
- }
60
+ if (terminalAuth?.token) _token = terminalAuth.token;
61
+ }
62
+
63
+ // THE TOKEN IS THE IDENTITY.
64
+ //
65
+ // The handle used to be read from config.json independently of the token, so the
66
+ // two could name different people and nothing noticed: the client displayed
67
+ // @brightseth, the server saw @vibetester1, and a DM went out as the wrong
68
+ // principal while reporting success. (Found by the first terminal-to-terminal
69
+ // test, issue #107 — a self-send recorded in the database.)
70
+ //
71
+ // A stored handle is a label someone wrote down; the token is what the server
72
+ // acts on. When they disagree, the file is wrong — never the credential.
73
+ const stored = cfg.handle || cfg.username || null;
74
+ _handle = _token ? handleFromToken(_token) : null;
75
+
76
+ if (_token && !_handle) {
77
+ // A token we cannot attribute is one we must not act under.
78
+ console.error('[auth-store] Token carries no identity — treating as signed out');
79
+ _token = null;
80
+ } else if (_token && stored && stored !== _handle) {
81
+ console.error(
82
+ `[auth-store] Stored handle @${stored} does not own this session — acting as @${_handle}`
83
+ );
46
84
  }
47
85
 
48
86
  _hydrated = true;
@@ -65,6 +103,17 @@ function setToken(token) {
65
103
  const hadToken = !!_token;
66
104
  _token = token;
67
105
 
106
+ // Identity travels WITH the credential (see hydrate). A new token can belong to a
107
+ // different person — after a re-auth, an account switch, or a cross-client sync —
108
+ // so the handle is re-derived here rather than left pointing at the previous one.
109
+ if (token) {
110
+ const who = handleFromToken(token);
111
+ if (who && who !== _handle) {
112
+ if (_handle) console.error(`[auth-store] Now acting as @${who} (was @${_handle})`);
113
+ _handle = who;
114
+ }
115
+ }
116
+
68
117
  if (!hadToken && token) {
69
118
  console.error('[auth-store] Token set (was empty)');
70
119
  } else if (hadToken && token && token !== _token) {
package/index.js CHANGED
@@ -665,8 +665,14 @@ class VibeMCPServer {
665
665
 
666
666
  async autoPresence() {
667
667
  try {
668
- const handle = config.getHandle();
669
- if (!handle) return; // Not authenticated yet
668
+ // Presence follows the CREDENTIAL, not a handle written in a file. This used to
669
+ // read config.getHandle() and announce "Auto-connected" on the strength of that
670
+ // alone — so an expired or foreign session still printed a confident green line
671
+ // and started a heartbeat that could only 401. That is the client-side version
672
+ // of the bug where an agent holds a green dot and never reads its mail: the dot
673
+ // has to mean someone is actually reachable. (issues #107, #91)
674
+ const handle = authStore.getHandle();
675
+ if (!handle || !authStore.isAuthenticated()) return;
670
676
 
671
677
  const one_liner = config.getOneLiner() || '';
672
678
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) — the /vibe kernel",
6
6
  "main": "index.js",
package/tools/inbox.js CHANGED
@@ -55,8 +55,18 @@ function getStatusLabel(user) {
55
55
  return 'away';
56
56
  }
57
57
 
58
- // Build recommended connections display for empty/caught-up inbox
58
+ // Build recommended connections display for empty/caught-up inbox.
59
+ //
60
+ // OFF by default. /vibe is a private fabric (docs/PRIVATE-FABRIC.md): discovery and
61
+ // cold introductions are out of scope, so an empty inbox has no business proposing
62
+ // strangers. It read especially badly on a brand-new account — five suggestions whose
63
+ // statuses were all the identical filler "something cool · Building the exact same
64
+ // thing", on the first screen an invitee ever sees (issue #108). An empty inbox
65
+ // should simply be empty; VIBE_EXTRAS=1 restores this with the rest of the culture
66
+ // layer for anyone who wants it.
59
67
  async function buildRecommendationsDisplay(myHandle) {
68
+ if (process.env.VIBE_EXTRAS !== '1') return null;
69
+
60
70
  // Fetch recommendations from relevancy API
61
71
  const relevancy = await fetchRelevantUsers(myHandle, 'dm_suggest', 5);
62
72
 
package/tools/init.js CHANGED
@@ -243,7 +243,13 @@ async function sendPersonalizedWelcome(handle, oneLiner) {
243
243
 
244
244
  const LOGIN_URL = 'https://www.slashvibe.dev/login';
245
245
  const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
246
- const AUTH_TIMEOUT_MS = 120000; // 2 minutes
246
+ // A first sign-in is not two minutes of typing: it can be a GitHub login, 2FA, a
247
+ // password manager, or switching accounts. The old fuse expired mid-flow for people
248
+ // who WROTE this client (issue #108), so it is now generous.
249
+ const AUTH_TIMEOUT_MS = 300000; // 5 minutes to complete the browser flow
250
+ // After we stop waiting, keep the socket up briefly so a late callback still gets a
251
+ // real page instead of the browser's connection-refused error.
252
+ const LATE_CALLBACK_GRACE_MS = 300000; // 5 more minutes to answer honestly
247
253
 
248
254
  /**
249
255
  * Send welcome message from @seth (founder)
@@ -314,18 +320,67 @@ function openBrowser(url) {
314
320
  function waitForCallback(requestedHandle, one_liner) {
315
321
  return new Promise((resolve, reject) => {
316
322
  let resolved = false;
323
+ let timedOut = false;
317
324
 
318
325
  const server = http.createServer(async (req, res) => {
319
326
  const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
320
327
 
328
+ // A callback that arrives after we stopped waiting still deserves an answer.
329
+ // Anything is better than the browser's raw connection-refused page (issue #108).
330
+ if (timedOut && url.pathname === '/callback') {
331
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
332
+ res.end(`<!DOCTYPE html>
333
+ <html lang="en"><head><meta charset="UTF-8">
334
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
335
+ <title>sign-in timed out · /vibe</title>
336
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
337
+ <style>
338
+ *{margin:0;padding:0;box-sizing:border-box}
339
+ body{background:var(--bg,#0A0A0A);color:var(--dim,#9CA3AF);font-family:var(--mono,ui-monospace,Menlo,monospace);
340
+ font-size:var(--t-14,14px);line-height:1.6;min-height:100vh;display:flex;align-items:center;
341
+ justify-content:center;padding:24px}
342
+ .card{border:1px solid var(--line,#1F2937);border-radius:var(--r-lg,10px);background:var(--panel,#111316);
343
+ padding:32px;max-width:420px;width:100%}
344
+ .logo{color:var(--ink,#E0E0E0);font-size:var(--t-16,16px);margin-bottom:24px}
345
+ .logo span{color:var(--blue,#6B8FFF)}
346
+ h1{color:var(--ink,#E0E0E0);font-size:var(--t-20,20px);font-weight:600;margin-bottom:8px}
347
+ code{color:var(--ink,#E0E0E0);background:var(--bg,#0A0A0A);border:1px solid var(--line,#1F2937);
348
+ border-radius:var(--r-sm,6px);padding:1px 6px}
349
+ .next{margin-top:24px;padding-top:16px;border-top:1px solid var(--line,#1F2937);font-size:var(--t-13,13px)}
350
+ </style></head>
351
+ <body><div class="card">
352
+ <div class="logo">/<span>vibe</span></div>
353
+ <h1>sign-in took too long</h1>
354
+ <p>your terminal stopped waiting, so this sign-in didn't finish. nothing is broken and
355
+ nothing was saved.</p>
356
+ <div class="next">back in your terminal, say <code>vibe init</code> and it will open a fresh
357
+ sign-in. this window can be closed.</div>
358
+ </div></body></html>`);
359
+ return;
360
+ }
361
+
321
362
  // Handle callback
322
363
  if (url.pathname === '/callback') {
323
364
  const token = url.searchParams.get('token');
324
365
  const callbackHandle = url.searchParams.get('handle');
325
366
 
326
367
  if (token && callbackHandle) {
327
- // Save the token and handle
328
- const finalHandle = requestedHandle || callbackHandle;
368
+ // WHO AUTHENTICATED WINS — not who we asked for.
369
+ //
370
+ // This was `requestedHandle || callbackHandle`, i.e. the handle passed into
371
+ // vibe_init overrode the identity the OAuth flow actually returned. Ask to
372
+ // sign in as @a while the browser is signed in as @b and the client stores
373
+ // @b's token under @a's name: the banner says @a, every message goes out as
374
+ // @b. That is exactly the mismatch issue #107 records, reproduced here.
375
+ //
376
+ // A requested handle is a preference for what to CALL a brand-new account,
377
+ // never a claim about whose credential this is.
378
+ const finalHandle = callbackHandle;
379
+ if (requestedHandle && requestedHandle !== callbackHandle) {
380
+ console.error(
381
+ `[vibe_init] Signed in as @${callbackHandle} (you asked for @${requestedHandle}) — using the account that authenticated.`
382
+ );
383
+ }
329
384
 
330
385
  // Save to config (file persistence for restarts)
331
386
  config.saveAuthToken(token);
@@ -364,228 +419,64 @@ function waitForCallback(requestedHandle, one_liner) {
364
419
  <head>
365
420
  <meta charset="UTF-8">
366
421
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
367
- <title>Welcome to /vibe!</title>
368
- <link rel="preconnect" href="https://fonts.googleapis.com">
369
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
370
- <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
422
+ <title>signed in to /vibe</title>
423
+ <!-- ROOM TONE, from the one source of record. This page is the LAST thing an
424
+ invitee sees before the product, so it must not look like a different one:
425
+ it used neon #00FF88, cyan, glow shadows, CRT scanlines and two arcade fonts
426
+ — every item on the "not this" list. The user just completed a browser OAuth,
427
+ so they are online by definition and the hosted token file is safe to link. -->
428
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
371
429
  <style>
372
430
  * { margin: 0; padding: 0; box-sizing: border-box; }
373
-
374
- :root {
375
- --neon-green: #00FF88;
376
- --neon-cyan: #00FFFF;
377
- --deep-black: #0A0A0A;
378
- --glow-green: 0 0 15px #00FF88;
379
- }
380
-
381
431
  body {
382
- font-family: 'VT323', monospace;
383
- background: var(--deep-black);
432
+ background: var(--bg, #0A0A0A);
433
+ color: var(--dim, #9CA3AF);
434
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
435
+ font-size: var(--t-14, 14px);
436
+ line-height: 1.6;
384
437
  min-height: 100vh;
385
- display: flex;
386
- align-items: center;
387
- justify-content: center;
388
- color: #fff;
389
- overflow: hidden;
390
- position: relative;
391
- }
392
-
393
- /* Static CRT Scanline Effect - no animation */
394
- body::before {
395
- content: '';
396
- position: fixed;
397
- top: 0; left: 0;
398
- width: 100%; height: 100%;
399
- background: repeating-linear-gradient(
400
- 0deg,
401
- rgba(0, 0, 0, 0.1),
402
- rgba(0, 0, 0, 0.1) 1px,
403
- transparent 1px,
404
- transparent 2px
405
- );
406
- pointer-events: none;
407
- z-index: 1000;
408
- }
409
-
410
- /* Static vignette */
411
- body::after {
412
- content: '';
413
- position: fixed;
414
- top: 0; left: 0;
415
- width: 100%; height: 100%;
416
- background: radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
417
- pointer-events: none;
418
- z-index: 999;
419
- }
420
-
421
- /* Static decorative symbols - NO animation */
422
- .particles {
423
- position: fixed;
424
- top: 0; left: 0;
425
- width: 100%; height: 100%;
426
- pointer-events: none;
427
- z-index: 1;
428
- opacity: 0.15;
429
- }
430
-
431
- .particle {
432
- position: absolute;
433
- font-size: 20px;
434
- color: var(--neon-green);
435
- }
436
-
437
- .container {
438
- background: rgba(0, 255, 136, 0.03);
439
- border: 2px solid var(--neon-green);
440
- padding: 48px 64px;
441
- max-width: 480px;
442
- width: 90%;
443
- text-align: center;
444
- position: relative;
445
- z-index: 10;
446
- box-shadow: var(--glow-green);
447
- animation: fadeIn 0.4s ease-out forwards;
448
- }
449
-
450
- @keyframes fadeIn {
451
- from { opacity: 0; transform: scale(0.95); }
452
- to { opacity: 1; transform: scale(1); }
453
- }
454
-
455
- /* Corner decorations */
456
- .container::before, .container::after {
457
- content: '+';
458
- position: absolute;
459
- font-family: 'VT323', monospace;
460
- font-size: 24px;
461
- color: var(--neon-green);
462
- }
463
- .container::before { top: 8px; left: 12px; }
464
- .container::after { bottom: 8px; right: 12px; }
465
-
466
- .logo {
467
- font-family: 'Press Start 2P', cursive;
468
- font-size: 28px;
469
- color: var(--neon-green);
470
- text-shadow: var(--glow-green);
471
- margin-bottom: 24px;
472
- }
473
-
474
- .checkmark {
475
- width: 80px;
476
- height: 80px;
477
- margin: 0 auto 24px;
438
+ display: flex; align-items: center; justify-content: center;
439
+ padding: var(--s-6, 24px);
478
440
  }
479
-
480
- .checkmark svg {
481
- width: 100%;
482
- height: 100%;
483
- fill: none;
484
- stroke: var(--neon-green);
485
- stroke-width: 4;
486
- stroke-linecap: round;
487
- stroke-linejoin: round;
488
- filter: drop-shadow(0 0 8px var(--neon-green));
489
- animation: drawCheck 0.5s ease-out forwards;
490
- }
491
-
492
- @keyframes drawCheck {
493
- 0% { stroke-dasharray: 100; stroke-dashoffset: 100; }
494
- 100% { stroke-dasharray: 100; stroke-dashoffset: 0; }
495
- }
496
-
497
- .welcome {
498
- font-size: 28px;
499
- color: rgba(255, 255, 255, 0.9);
500
- margin-bottom: 8px;
501
- }
502
-
503
- .handle {
504
- color: var(--neon-green);
505
- text-shadow: 0 0 10px var(--neon-green);
506
- }
507
-
508
- .status {
509
- font-size: 20px;
510
- color: var(--neon-cyan);
511
- margin: 20px 0;
512
- }
513
-
514
- .close-msg {
515
- font-size: 18px;
516
- color: rgba(255, 255, 255, 0.6);
517
- margin-top: 24px;
518
- padding: 12px 20px;
519
- border: 1px dashed rgba(255, 255, 255, 0.3);
441
+ .card {
442
+ border: 1px solid var(--line, #1F2937);
443
+ border-radius: var(--r-lg, 10px);
444
+ background: var(--panel, #111316);
445
+ padding: var(--s-8, 32px);
446
+ max-width: 420px; width: 100%;
520
447
  }
521
-
522
- .ascii {
523
- font-size: 14px;
524
- color: rgba(0, 255, 136, 0.3);
525
- margin-top: 20px;
526
- }
527
-
528
- /* Respect reduced motion preference */
529
- @media (prefers-reduced-motion: reduce) {
530
- *, *::before, *::after {
531
- animation: none !important;
532
- transition: none !important;
533
- }
448
+ .logo { color: var(--ink, #E0E0E0); font-size: var(--t-16, 16px); margin-bottom: var(--s-6, 24px); }
449
+ .logo span { color: var(--blue, #6B8FFF); }
450
+ .line { color: var(--ink, #E0E0E0); font-size: var(--t-20, 20px); margin-bottom: var(--s-2, 8px); }
451
+ /* green is presence and nothing else — here it states a fact that is now true:
452
+ this handle is on the board. */
453
+ .dot { color: var(--green, #22c55e); }
454
+ .meta { font-size: var(--t-13, 13px); }
455
+ .next {
456
+ margin-top: var(--s-6, 24px); padding-top: var(--s-4, 16px);
457
+ border-top: 1px solid var(--line, #1F2937); font-size: var(--t-13, 13px);
534
458
  }
535
-
536
- /* Stop all animations after load */
537
- .animations-stopped *,
538
- .animations-stopped *::before,
539
- .animations-stopped *::after {
540
- animation: none !important;
459
+ code {
460
+ color: var(--ink, #E0E0E0);
461
+ background: var(--bg, #0A0A0A);
462
+ border: 1px solid var(--line, #1F2937);
463
+ border-radius: var(--r-sm, 6px);
464
+ padding: 1px 6px;
541
465
  }
466
+ .close { color: var(--faint, #6B7280); font-size: var(--t-11, 11px); margin-top: var(--s-6, 24px); }
542
467
  </style>
543
468
  </head>
544
469
  <body>
545
- <div class="particles" id="particles"></div>
546
-
547
- <div class="container">
548
- <div class="logo">/vibe</div>
549
-
550
- <div class="checkmark">
551
- <svg viewBox="0 0 52 52">
552
- <circle cx="26" cy="26" r="22" stroke-opacity="0.3"/>
553
- <path d="M14 27l8 8 16-16"/>
554
- </svg>
470
+ <div class="card">
471
+ <div class="logo">/<span>vibe</span></div>
472
+ <p class="line"><span class="dot">&#x1F7E2;</span> you're in, @${finalHandle}</p>
473
+ <p class="meta">your session is signed in and you're on the board.</p>
474
+ <div class="next">
475
+ back in your terminal, say <code>vibe who</code> to see who's around,
476
+ or <code>vibe inbox</code> if someone already wrote to you.
555
477
  </div>
556
-
557
- <p class="welcome">Welcome, <span class="handle">@${finalHandle}</span></p>
558
- <p class="status">Authentication successful</p>
559
-
560
- <p class="close-msg">You can now close this window</p>
561
-
562
- <div class="ascii">═══════════════════════════════</div>
478
+ <p class="close">you can close this window.</p>
563
479
  </div>
564
-
565
- <script>
566
- // Create STATIC decorative symbols (no animation)
567
- (function() {
568
- var container = document.getElementById('particles');
569
- var symbols = ['>', '<', '/', '*', '#', '@', '~'];
570
- var positions = [
571
- {x: 10, y: 15}, {x: 85, y: 20}, {x: 20, y: 75}, {x: 75, y: 80},
572
- {x: 50, y: 10}, {x: 15, y: 45}, {x: 88, y: 55}, {x: 45, y: 85}
573
- ];
574
- positions.forEach(function(pos, i) {
575
- var particle = document.createElement('div');
576
- particle.className = 'particle';
577
- particle.textContent = symbols[i % symbols.length];
578
- particle.style.left = pos.x + '%';
579
- particle.style.top = pos.y + '%';
580
- container.appendChild(particle);
581
- });
582
- })();
583
-
584
- // Stop any remaining animations after 2 seconds (safety net)
585
- setTimeout(function() {
586
- document.body.classList.add('animations-stopped');
587
- }, 2000);
588
- </script>
589
480
  </body>
590
481
  </html>`);
591
482
 
@@ -616,11 +507,23 @@ function waitForCallback(requestedHandle, one_liner) {
616
507
  console.log(`[vibe_init] Callback server listening on port ${CALLBACK_PORT}`);
617
508
  });
618
509
 
619
- // Timeout after 2 minutes
510
+ // Stop WAITING at the timeout, but keep LISTENING for a grace period.
511
+ //
512
+ // These were the same moment before, and it produced the worst screen in the
513
+ // product: sign-in takes longer than the fuse (first-time GitHub, 2FA, a
514
+ // password manager, switching accounts — all normal), the listener closes, and
515
+ // the browser lands on a raw ERR_CONNECTION_REFUSED holding a valid token, with
516
+ // no explanation and nothing to click. It happened twice in one evening to
517
+ // people who built this. An invitee reads that as "broken". (issue #108)
518
+ //
519
+ // So: the promise rejects on time (the caller must not hang), and the socket
520
+ // stays up long enough to answer a late callback with a page that says what
521
+ // happened and how to retry.
620
522
  setTimeout(() => {
621
523
  if (!resolved) {
622
- server.close();
524
+ timedOut = true;
623
525
  reject(new Error('AUTH_TIMEOUT'));
526
+ setTimeout(() => { if (!resolved) server.close(); }, LATE_CALLBACK_GRACE_MS);
624
527
  }
625
528
  }, AUTH_TIMEOUT_MS);
626
529
  });
@@ -805,19 +708,19 @@ To check messages: \`vibe inbox\`${emailNudge}`
805
708
  const whoNudge = presence.humans > 1
806
709
  ? `**"vibe who"** — see the ${presence.humans} builders online right now`
807
710
  : `**"vibe who"** — see who's building right now`;
808
- // Received collaboration: instead of handing the newcomer a cold to-do
809
- // list ("go DM a stranger"), have their agent introduce them to someone
810
- // online working on something related — they land mid-conversation. The
811
- // directive below tells the model to call vibe_intro with what this user
812
- // is building THIS session (context only /vibe can see). vibe_intro
813
- // handles the empty-room / no-match cases gracefully, so it's always safe
814
- // to attempt. Draft-first: it proposes, the user says yes, then send.
815
- const nextSteps = `\n\n---\n**You're in — let's land you in a real conversation, not an empty inbox.**\n`
816
- + `\n→ _Agent: in one line, summarize what this user is building in the current session_`
817
- + ` _(infer it from their open files, project, and recent work), then call the_ **\`vibe_intro\`** _tool with that as \`building\`._`
818
- + ` _It finds someone online working on something related and drafts a warm intro. Show them the match, and if they're up for it, call \`vibe_intro\` again with \`send: true\`._\n`
819
- + `\nPrefer to look around first? ${whoNudge}, or **"vibe ship <what you're building>"** to put yourself on the board.\n\n`
820
- + `_Stuck? Say **"vibe help"**._`;
711
+ // You are here because someone you work with invited you. So the first move
712
+ // is answering THEM, not being matched with a stranger.
713
+ //
714
+ // This used to instruct the agent to call `vibe_intro` — auto-introduce the
715
+ // newcomer to whoever seemed related. That tool is RETIRED (docs/PRIVATE-FABRIC.md:
716
+ // cold introduction is out of scope), so the very first instruction a new
717
+ // invitee received pointed at the discovery model we deleted.
718
+ const nextSteps = unreadCount > 0
719
+ ? `\n\n---\n**Someone is already waiting on you.** Say **"vibe inbox"** to read it, then **"vibe reply"** to answer.\n\n`
720
+ + `_Stuck? Say **"vibe help"**._`
721
+ : `\n\n---\n**You're in.** Say **"vibe dm @<handle>"** to reach whoever invited you — they'll get it in their next session, whether or not they're online now.\n`
722
+ + `\n${whoNudge}.\n\n`
723
+ + `_Stuck? Say **"vibe help"**._`;
821
724
 
822
725
  // One-time email nudge — only for users we can't otherwise reach (no
823
726
  // address on file). This is the return loop: a DM that lands while you're
package/version.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
- "version": "0.8.1",
2
+ "version": "0.8.2",
3
3
  "updated": "2026-07-31",
4
- "changelog": "Security release \u2014 publish the reviewed client. An external review found the registry still served the pre-remediation package, so this cut is what the fixes were for. Notifications: sender text is no longer AppleScript source (a DM could escape the string literal and be evaluated); it is passed as arguments to a static script. Untrusted text: one shared renderer now inerts every foreign value that reaches the model \u2014 statuses, activity, previews and guest messages, not just message bodies \u2014 so a status cannot forge a board row or open a code span. Auth: `vibe init` verifies a saved session with the server instead of trusting the token file, and tells offline apart from expired.",
4
+ "changelog": "Identity and first-run honesty. The client could display one handle while acting as another: the shown name came from a config file and the acting identity from a token, and nothing tied them together \u2014 so a DM could go out as the wrong account and still report success. The token is now the identity everywhere, including the sign-in flow, which used to let a requested handle override the account that actually authenticated. Presence no longer announces a connection it has not verified. Sign-in gets five minutes instead of two, and a late callback now gets a page explaining what happened instead of the browser's connection-refused error. First-run copy points at the person who invited you rather than at strangers.",
5
5
  "features": [
6
- "Notifications pass sender text as arguments, never as AppleScript source",
7
- "One shared renderer inerts every foreign field on every model-facing surface",
8
- "vibe init trusts the server, not the local token file (offline != expired)"
6
+ "The token is the identity \u2014 a stored handle can never override the credential",
7
+ "Sign-in keeps the account that authenticated, not the one that was requested",
8
+ "Presence announces a session only after it is verified",
9
+ "Sign-in window 2 -> 5 minutes, with an honest page if it still expires",
10
+ "First run points at your inviter; stranger suggestions move behind VIBE_EXTRAS"
9
11
  ],
10
12
  "deprecated": [],
11
13
  "breaking": false,