slashvibe-mcp 0.8.19 โ†’ 0.8.20

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/index.js CHANGED
@@ -586,16 +586,21 @@ class VibeMCPServer {
586
586
  global.vibeNotifier?.emitImmediate();
587
587
  }
588
588
 
589
- return {
590
- jsonrpc: '2.0',
591
- id,
592
- result: {
593
- content: [{
594
- type: 'text',
595
- text: `> _You tried to **${actionName}** โ€” signing you in first._\n\n${initDisplay}\n\n---\n๐Ÿ’ก **You're in!** Try your \`${actionName}\` command again.`
596
- }]
597
- }
589
+ // Sign-in is non-blocking now: init returns auth_required and the
590
+ // person finishes in a browser. Only claim "You're in!" when the
591
+ // credential actually exists (review P1) โ€” otherwise relay init's
592
+ // honest state and its structured mirror.
593
+ const signedIn = isAuthed();
594
+ const gateResult = {
595
+ content: [{
596
+ type: 'text',
597
+ text: signedIn
598
+ ? `> _You tried to **${actionName}** โ€” signing you in first._\n\n${initDisplay}\n\n---\n๐Ÿ’ก **You're in!** Try your \`${actionName}\` command again.`
599
+ : `> _You tried to **${actionName}** โ€” sign in first._\n\n${initDisplay}\n\n---\n_Then say \`${actionName}\` again._`
600
+ }]
598
601
  };
602
+ if (initResult.structured) gateResult.structuredContent = initResult.structured;
603
+ return { jsonrpc: '2.0', id, result: gateResult };
599
604
  } catch (e) {
600
605
  return {
601
606
  jsonrpc: '2.0',
@@ -30,7 +30,7 @@ class NotificationEmitter {
30
30
  this.debounceTimers[reason] = setTimeout(() => {
31
31
  try {
32
32
  this.server.notification({
33
- method: "notifications/list_changed"
33
+ method: "notifications/tools/list_changed"
34
34
  });
35
35
  delete this.debounceTimers[reason];
36
36
  } catch (e) {
@@ -47,7 +47,7 @@ class NotificationEmitter {
47
47
  emitImmediate() {
48
48
  try {
49
49
  this.server.notification({
50
- method: "notifications/list_changed"
50
+ method: "notifications/tools/list_changed"
51
51
  });
52
52
  } catch (e) {
53
53
  // Silent fail
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.19",
3
+ "version": "0.8.20",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) \u2014 the /vibe kernel",
6
6
  "main": "index.js",
package/store/api.js CHANGED
@@ -654,6 +654,10 @@ async function getInboxV1(handle) {
654
654
  messages: [],
655
655
  unread: t.unread || 0,
656
656
  lastMessage: t.last_message?.body,
657
+ // The stable id + author of the newest message โ€” what a reply MUST
658
+ // name explicitly (first-five-minutes repair: no guessed targets).
659
+ lastMessageId: t.last_message?.id || null,
660
+ lastFrom: t.last_message?.from || null,
657
661
  lastTimestamp: t.last_message?.created_at ? new Date(t.last_message.created_at).getTime() : null
658
662
  }));
659
663
  }
package/tools/help.js CHANGED
@@ -45,7 +45,7 @@ The kernel: presence + messaging across terminal coding sessions
45
45
 
46
46
  ### Messaging
47
47
  - \`vibe dm @handle "message"\` โ€” Send a direct message. Lands now if they're in a session, waits for their next turn if not
48
- - \`vibe reply "message"\` โ€” Reply to your most recent unread (or a specific person)
48
+ - \`vibe reply\` with reply_to: "#id" โ€” Answer one specific message (ids show in \`vibe inbox\`)
49
49
  - \`vibe inbox\` โ€” See unread messages and recent threads
50
50
 
51
51
  ### Setup
package/tools/inbox.js CHANGED
@@ -374,10 +374,11 @@ async function handler(args) {
374
374
  // were rendering as the same sentence. "Recent: @a, @b, @c" also named the
375
375
  // handles without saying what they were: recent conversations, recent arrivals,
376
376
  // or a suggestion? Naming the relationship costs three words.
377
- const recentHandles = sorted.slice(0, 3).map((th) => `@${th.handle}`).join(', ');
377
+ // ids ride inline so the summary stays one compact line (and greppable)
378
+ const recentLine = sorted.slice(0, 3).map((th) => `@${th.handle}${th.lastMessageId ? ` (#${th.lastMessageId})` : ''}`).join(', ');
378
379
  return {
379
- display: recentHandles
380
- ? `๐Ÿ“ญ nothing unread.\n\n_your recent threads:_ ${recentHandles}\n_open one with_ \`vibe inbox @handle\``
380
+ display: recentLine
381
+ ? `๐Ÿ“ญ nothing unread.\n\n_your recent threads:_ ${recentLine}\n_open one with_ \`vibe inbox @handle\` _ยท reply to a message exactly with its #id_`
381
382
  : '๐Ÿ“ญ nothing unread.',
382
383
  actions: formatActions(actions.recommendedConnections([]))
383
384
  };
@@ -475,12 +476,18 @@ async function handler(args) {
475
476
  // Line 3: Divider
476
477
  display += 'โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n';
477
478
 
478
- // Line 4+: Expanded list with counts and badges
479
+ // Line 4+: one line per unread thread โ€” handle, count, and the STABLE id of
480
+ // its newest message so a reply can name its target exactly (first-five-
481
+ // minutes repair: vibe_reply never guesses). Compact: the id is a copyable
482
+ // suffix, not the headline.
479
483
  const expanded = unreadSenders.map(t => {
480
484
  const agent = t.isAgent ? ' ๐Ÿค–' : '';
481
- return `@${t.handle} (${t.unread})${agent}`;
482
- }).join(' โ€ข ');
485
+ const idTag = t.lastMessageId ? ` ยท #${t.lastMessageId}` : '';
486
+ const who = t.lastFrom && t.lastFrom !== t.handle ? '' : '';
487
+ return `โ€ข @${t.handle} (${t.unread})${agent}${idTag}${who}`;
488
+ }).join('\n');
483
489
  display += expanded;
490
+ display += `\n\n_reply to one exactly:_ \`vibe reply\` with reply_to: "<id>"`;
484
491
 
485
492
  // Build response with optional hints for structured flows
486
493
  const response = { display };
@@ -491,6 +498,8 @@ async function handler(args) {
491
498
  response.unread_count = totalUnread;
492
499
  response.threads = unreadSenders.map(t => ({
493
500
  handle: t.handle,
501
+ thread_id: t.thread_id || null,
502
+ last_message_id: t.lastMessageId || null,
494
503
  unread: t.unread,
495
504
  preview: truncate(t.lastMessage || '', 40)
496
505
  }));
package/tools/init.js CHANGED
@@ -267,7 +267,7 @@ async function sendWelcomeMessage(handle, one_liner) {
267
267
 
268
268
  const definition = {
269
269
  name: 'vibe_init',
270
- description: `Join /vibe social network. Opens the browser for GitHub sign-in โ€” NO INPUT NEEDED; the user's GitHub username becomes their handle automatically. This BLOCKS for up to 5 minutes waiting for the browser login to finish, so BEFORE it returns the user sees only a spinner. IMPORTANT: right when you call this, tell the user in your own words that their browser is opening to sign in with GitHub, then finish the login there and come back.`,
270
+ description: `Sign in to /vibe with GitHub โ€” NO INPUT NEEDED; the GitHub username becomes the handle. Returns IMMEDIATELY with an auth_required state, the login URL, and one sentence to relay: 'Open this, sign in with GitHub, then say vibe start.' It may open a browser when one is available locally (never over SSH/headless); it never waits. Sign-in completes in the background; the next vibe start recognizes it.`,
271
271
  inputSchema: {
272
272
  type: 'object',
273
273
  properties: {
@@ -288,9 +288,14 @@ const definition = {
288
288
  * Open URL in default browser
289
289
  */
290
290
  function openBrowser(url) {
291
+ // Resolves to whether a browser was actually asked to open. Over SSH or in
292
+ // a headless/CI shell we do not try: `open` on a remote Mac would pop the
293
+ // window on a screen the person is not looking at, and a failed attempt
294
+ // must be REPORTED so the URL becomes the honest next action.
295
+ const remote = Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.CI);
296
+ if (remote) return Promise.resolve(false);
291
297
  const platform = process.platform;
292
298
  let command;
293
-
294
299
  if (platform === 'darwin') {
295
300
  command = `open "${url}"`;
296
301
  } else if (platform === 'win32') {
@@ -298,14 +303,135 @@ function openBrowser(url) {
298
303
  } else {
299
304
  command = `xdg-open "${url}"`;
300
305
  }
306
+ return new Promise((resolve) => {
307
+ // A launcher still running after the cap is 'unknown' โ€” never reported as
308
+ // failed while it may yet open a window (review P2).
309
+ const timer = setTimeout(() => resolve('unknown'), 5000);
310
+ exec(command, (err) => {
311
+ clearTimeout(timer);
312
+ if (err) console.error('[vibe_init] Could not open a browser:', err.message);
313
+ resolve(!err);
314
+ });
315
+ });
316
+ }
301
317
 
302
- exec(command, (err) => {
303
- if (err) {
304
- console.error('[vibe_init] Failed to open browser:', err.message);
305
- }
318
+ // โ”€โ”€ Non-blocking sign-in (first-five-minutes repair, 2026-08-30) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
319
+ // The tool call NEVER waits for OAuth. One flow at a time lives here; the
320
+ // callback listener stays alive after the tool returns, and completion is
321
+ // persisted in the background so the NEXT vibe_start recognizes the
322
+ // credential. Expired flows are replaced with a fresh one and say so.
323
+ const AUTH_SENTENCE = 'Open this, sign in with GitHub, then say vibe start.';
324
+ let pendingAuth = null; // { oauth, loginUrl, startedAt, browserOpened, expired }
325
+ let pendingAuthCreation = null; // in-flight ensureAuthFlow, so concurrent starts share ONE flow (review P1)
326
+
327
+ async function completeSignIn({ token, handle: finalHandle, actor }, one_liner) {
328
+ if (actor) await actorSession.installOAuthSession(actor);
329
+ else await actorSession.clearActorSession();
330
+ config.saveAuthToken(token);
331
+ config.setSessionIdentity(finalHandle, one_liner || '');
332
+ authStore.setToken(token);
333
+ authStore.setHandle(finalHandle);
334
+ authStore.setOneLiner(one_liner || '');
335
+ const authConfig = config.load();
336
+ authConfig.handle = finalHandle;
337
+ authConfig.one_liner = one_liner || '';
338
+ authConfig.authMethod = 'browser';
339
+ authConfig.pendingAuth = false;
340
+ config.save(authConfig);
341
+ // The toolset just changed โ€” tell the host now, not on the next call.
342
+ global.vibeNotifier?.emitImmediate();
343
+ // Presence + welcome are best-effort and must never block or throw here.
344
+ try { await store.registerSession(config.getSessionId(), finalHandle, one_liner); } catch {}
345
+ try { await store.heartbeat(finalHandle, one_liner); } catch {}
346
+ try { discord.postJoin(finalHandle, one_liner); } catch {}
347
+ try { await Promise.race([sendPersonalizedWelcome(finalHandle, one_liner), new Promise((r) => setTimeout(r, 2500))]); } catch {}
348
+ }
349
+
350
+ async function ensureAuthFlow(opts) {
351
+ if (pendingAuth && !pendingAuth.expired) {
352
+ return { ...pendingAuth, reused: true };
353
+ }
354
+ // Concurrent signed-out starts must not each bind a listener: the FIRST
355
+ // caller's creation promise is shared until pendingAuth exists.
356
+ if (pendingAuthCreation) {
357
+ const flow = await pendingAuthCreation;
358
+ return { ...flow, reused: true };
359
+ }
360
+ pendingAuthCreation = createAuthFlow(opts).finally(() => { pendingAuthCreation = null; });
361
+ return pendingAuthCreation;
362
+ }
363
+
364
+ async function createAuthFlow({ requestedHandle, one_liner }) {
365
+ const replacedExpired = Boolean(pendingAuth && pendingAuth.expired);
366
+ if (replacedExpired) {
367
+ // The expired listener must not linger through its grace period beside
368
+ // the replacement (review P2).
369
+ try { await pendingAuth.oauth.cancel(); } catch {}
370
+ pendingAuth = null;
371
+ }
372
+ const oauth = await beginOAuth({ requestedHandle, actorAware: true });
373
+ // 'unknown' until the launcher actually answers: a staggered caller that
374
+ // finds the flow mid-launch must not report `false` for a browser that is
375
+ // still opening (review P2). The field is updated in place when known.
376
+ const flow = { oauth, loginUrl: oauth.loginUrl, startedAt: Date.now(), browserOpened: 'unknown', expired: false, reused: false, replacedExpired };
377
+ pendingAuth = flow;
378
+ // Background completion: nobody awaits this. Success persists the credential;
379
+ // timeout marks the flow expired so the next start issues a fresh link.
380
+ oauth.waitForCallback().then(
381
+ (result) => completeSignIn(result, one_liner).catch((e) => console.error('[vibe_init] sign-in completion failed:', e.message)).finally(() => { if (pendingAuth === flow) pendingAuth = null; }),
382
+ (err) => { flow.expired = true; if (err?.message !== 'AUTH_TIMEOUT') console.error('[vibe_init] sign-in flow ended:', err?.message || err); }
383
+ );
384
+ // Kick the launcher off and report only what is known within a short
385
+ // budget โ€” a slow launcher yields 'unknown', never a wait and never a lie.
386
+ flow.browserPromise = openBrowser(oauth.loginUrl).then((result) => {
387
+ flow.browserOpened = result;
388
+ return result;
306
389
  });
390
+ await Promise.race([
391
+ flow.browserPromise,
392
+ new Promise((resolve) => setTimeout(resolve, 750)),
393
+ ]);
394
+ return flow;
395
+ }
396
+
397
+ function authRequiredResult(flow, presenceBanner) {
398
+ const lead = flow.replacedExpired
399
+ ? 'The earlier sign-in link expired โ€” here is a fresh one.'
400
+ : flow.reused
401
+ ? 'Still waiting for your sign-in.'
402
+ : flow.browserOpened === true
403
+ ? 'A browser window is opening for GitHub sign-in.'
404
+ : flow.browserOpened === 'unknown'
405
+ ? 'A browser may be opening; if it did not, use the link.'
406
+ : 'No browser could be opened from here.';
407
+ const structured = {
408
+ state: 'auth_required',
409
+ login_url: flow.loginUrl,
410
+ browser_opened: flow.browserOpened,
411
+ waiting_since: flow.startedAt,
412
+ next: 'vibe start',
413
+ sentence: AUTH_SENTENCE,
414
+ };
415
+ return {
416
+ display: `${presenceBanner || ''}\n\n**Sign in to /vibe**\n${lead}\n${AUTH_SENTENCE}\n\n${flow.loginUrl}`,
417
+ data: structured,
418
+ structured, // the dispatcher forwards `structured` as structuredContent (review P1)
419
+ };
420
+ }
421
+
422
+ // Test seam: reset the one pending flow between cases.
423
+ function _forceExpireForTest() {
424
+ if (pendingAuth) pendingAuth.expired = true;
425
+ }
426
+
427
+ async function _resetPendingAuth() {
428
+ const flow = pendingAuth;
429
+ pendingAuth = null;
430
+ pendingAuthCreation = null;
431
+ if (flow?.oauth) { try { await flow.oauth.cancel(); } catch {} }
307
432
  }
308
433
 
434
+
309
435
  async function handler(args) {
310
436
  const { handle, one_liner, auth_method } = args;
311
437
 
@@ -394,204 +520,43 @@ Heading out? \`vibe bye\` ends presence for this session โ€” you stay @${existin
394
520
  // ===========================================
395
521
  // Show welcome banner (pre-auth)
396
522
  // ===========================================
397
- const presence = await getPresenceCounts();
523
+ // Bounded: the pre-auth banner must never hold the sign-in response hostage
524
+ // to a slow presence fetch (review P1 โ€” 'return immediately').
525
+ const presence = await Promise.race([
526
+ getPresenceCounts(),
527
+ new Promise((resolve) => setTimeout(() => resolve({ online: 0, humans: 0, agents: 0 }), 1500)),
528
+ ]);
398
529
  const welcomeBanner = generatePreAuthBanner(presence);
399
530
 
400
531
  // ===========================================
401
532
  // BROWSER AUTH (Default): GitHub OAuth
402
533
  // ===========================================
403
534
  if (auth_method === 'browser' || !auth_method) {
404
- let oauth;
405
-
406
- // Save one_liner for callback handler
535
+ // Save one_liner for the completion handler
407
536
  const cfg = config.load();
408
537
  if (h) cfg.handle = h;
409
538
  cfg.one_liner = one_liner || '';
410
539
  cfg.pendingAuth = true;
411
540
  config.save(cfg);
412
541
 
542
+ let flow;
413
543
  try {
414
- // The callback listener is bound before this resolves. Only then is it
415
- // safe to hand the attempt-correlated URL to the browser.
416
- oauth = await beginOAuth({ requestedHandle: h, actorAware: true });
417
- openBrowser(oauth.loginUrl);
418
-
419
- // Wait for callback (blocks until auth completes or times out)
420
- const { token, handle: callbackHandle, actor } = await oauth.waitForCallback();
421
- const finalHandle = callbackHandle;
422
-
423
- // Actor state is a separate credential family. Install it before claiming
424
- // sign-in success; an account switch or failed shadow issuance must not leave a
425
- // previous principal's Actor bundle alive beside the new legacy session.
426
- if (actor) await actorSession.installOAuthSession(actor);
427
- else await actorSession.clearActorSession();
428
-
429
- // Save to config (file persistence for restarts)
430
- config.saveAuthToken(token);
431
- config.setSessionIdentity(finalHandle, one_liner || '');
432
-
433
- // PUSH to in-memory auth store (immediate propagation)
434
- authStore.setToken(token);
435
- authStore.setHandle(finalHandle);
436
- authStore.setOneLiner(one_liner || '');
437
-
438
- // Update shared config
439
- const authConfig = config.load();
440
- authConfig.handle = finalHandle;
441
- authConfig.one_liner = one_liner || '';
442
- authConfig.authMethod = 'browser';
443
- authConfig.pendingAuth = false;
444
- config.save(authConfig);
445
-
446
- // Register session with API
447
- const sessionId = config.getSessionId();
448
- await store.registerSession(sessionId, finalHandle, one_liner);
449
-
450
- // Send initial heartbeat
451
- await store.heartbeat(finalHandle, one_liner);
452
-
453
- // Post to Discord
454
- discord.postJoin(finalHandle, one_liner);
455
-
456
- const result = { success: true, handle: finalHandle };
457
-
458
- // Send personalized welcome and wait for it (2.5s timeout)
459
- let welcomeResult = null;
460
- try {
461
- welcomeResult = await Promise.race([
462
- sendPersonalizedWelcome(result.handle, one_liner),
463
- new Promise(resolve => setTimeout(() => resolve(null), 2500))
464
- ]);
465
- } catch (e) {
466
- console.error('[vibe_init] Welcome message failed:', e.message);
467
- }
468
-
469
- // Check for unread messages (includes the welcome we just sent)
470
- let unreadCount = 0;
471
- try {
472
- unreadCount = await store.getUnreadCount(result.handle);
473
- } catch (e) {}
474
-
475
- // Fetch GitHub friends (non-blocking, 3s timeout)
476
- let friendsData = null;
477
- try {
478
- const friendsPromise = fetchGitHubFriends(result.handle);
479
- friendsData = await Promise.race([
480
- friendsPromise,
481
- new Promise(resolve => setTimeout(() => resolve(null), 3000))
482
- ]);
483
- } catch (e) {}
484
-
485
- // Do we have an email to reach them at when a DM lands while away?
486
- // (non-blocking, 2s timeout โ€” never hold up onboarding for this)
487
- let hasEmail = null;
488
- try {
489
- hasEmail = await Promise.race([
490
- fetchHasEmail(config.getAuthToken()),
491
- new Promise(resolve => setTimeout(() => resolve(null), 2000))
492
- ]);
493
- } catch (e) {}
494
-
495
- // Generate authenticated banner with handle + unread (3 lines only - won't collapse)
496
- const authBanner = generateAuthBanner(result.handle, unreadCount, presence);
497
-
498
- // Build friends section if we have data
499
- let friendsSection = '';
500
- if (friendsData?.friendsOnVibe?.length > 0) {
501
- const friendHandles = friendsData.friendsOnVibe.map(f => `@${f.vibe_handle}`).join(', ');
502
- friendsSection = `\n\n๐Ÿค **${friendsData.friendsOnVibe.length} of your GitHub friends are here!**\n${friendHandles}`;
503
- } else if (friendsData?.totalContacts > 0) {
504
- friendsSection = `\n\n๐Ÿ‘‹ None of your GitHub friends are on /vibe yet โ€” say **"vibe invite"** to bring them in!`;
505
- }
506
-
507
- // Build welcome section - show inline if we have the message
508
- let welcomeSection = '';
509
- if (welcomeResult?.messageText) {
510
- welcomeSection = `\n\n---\n**๐Ÿ“จ Welcome from @seth:**\n\n> ${welcomeResult.messageText.split('\n').join('\n> ')}`;
511
- } else {
512
- welcomeSection = '\n\n---\n**๐Ÿ“จ @seth sent you a welcome message** โ€” say **"vibe inbox"** to read it.';
513
- }
514
-
515
- // Always close on a concrete next move toward the aha: your first DM
516
- // landing in someone else's terminal. Only commands that exist (who /
517
- // @handle / ship). Avoid pointing the reply at a specific founder handle
518
- // here โ€” it can misroute; reading the inbox is the safe path to reply.
519
- // Only promise a crowd of *humans* when there's actually one to meet โ€”
520
- // presence skews agent-heavy, and overselling an empty room is how the
521
- // green dot stops meaning anything. Agents still show in `vibe who`.
522
- const whoNudge = presence.humans > 1
523
- ? `**"vibe who"** โ€” see the ${presence.humans} builders online right now`
524
- : `**"vibe who"** โ€” see who's building right now`;
525
- // You are here because someone you work with invited you. So the first move
526
- // is answering THEM, not being matched with a stranger.
527
- //
528
- // This used to instruct the agent to call `vibe_intro` โ€” auto-introduce the
529
- // newcomer to whoever seemed related. That tool is RETIRED (docs/PRIVATE-FABRIC.md:
530
- // cold introduction is out of scope), so the very first instruction a new
531
- // invitee received pointed at the discovery model we deleted.
532
- const nextSteps = unreadCount > 0
533
- ? `\n\n---\n**Someone is already waiting on you.** Say **"vibe inbox"** to read it, then **"vibe reply"** to answer.\n\n`
534
- + `_Stuck? Say **"vibe help"**._`
535
- : `\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`
536
- + `\n${whoNudge}.\n\n`
537
- + `_Stuck? Say **"vibe help"**._`;
538
-
539
- // One-time email nudge โ€” only for users we can't otherwise reach (no
540
- // address on file). This is the return loop: a DM that lands while you're
541
- // away from Claude Code emails you so you actually come back.
542
- const emailNudge = (EXTRAS_ENABLED && hasEmail === false)
543
- ? `\n\n---\n๐Ÿ“ง **Don't miss a DM** โ€” add an email and /vibe pings you when someone messages you while you're away:\n**"vibe email you@example.com"** _(offline only ยท one-click unsubscribe)_`
544
- : '';
545
-
546
- return {
547
- display: authBanner + friendsSection + welcomeSection + nextSteps + emailNudge,
548
- onboarding: {
549
- isNewUser: true,
550
- handle: result.handle,
551
- hint: 'show_onboarding_options',
552
- hasWelcomeMessage: !!welcomeResult,
553
- welcomeText: welcomeResult?.messageText || null,
554
- friendsOnVibe: friendsData?.friendsOnVibe || [],
555
- inviteSuggestions: friendsData?.inviteSuggestions || []
556
- }
557
- };
558
-
559
- } catch (err) {
560
- if (err.message === 'AUTH_IN_PROGRESS') {
561
- return {
562
- display: `## A login is already running
563
-
564
- Another sign-in owns the callback listener. Finish that browser sign-in, or say
565
- **"let's vibe"** again to start a fresh attempt.`
566
- };
567
- }
568
-
569
- if (err.message === 'AUTH_TIMEOUT') {
544
+ flow = await ensureAuthFlow({ requestedHandle: h, one_liner });
545
+ } catch (error) {
546
+ if (error?.message === 'AUTH_IN_PROGRESS') {
570
547
  return {
571
- display: `## The sign-in timed out
572
-
573
- The browser login wasn't finished within 5 minutes โ€” no problem, just start it again.
574
-
575
- **1. Say "let's vibe"** to reopen the login.
576
- **2. Sign in with GitHub** in that tab, then come back here.
577
-
578
- _Tip: keep this window and the browser both visible so you can see when it finishes._`
548
+ display: `${welcomeBanner}\n\n**Sign in to /vibe**\nAnother /vibe session on this machine is already signing in โ€” finish that one, then say vibe start.`,
549
+ data: { state: 'auth_required', login_url: null, browser_opened: false, next: 'vibe start', sentence: AUTH_SENTENCE },
550
+ structured: { state: 'auth_required', login_url: null, browser_opened: false, next: 'vibe start', sentence: AUTH_SENTENCE },
579
551
  };
580
552
  }
581
-
582
- if (oauth) await oauth.cancel();
583
553
  return {
584
- display: `## Couldn't finish sign-in
585
-
586
- **What happened:** ${err.message}
587
-
588
- **Try this:**
589
- 1. Say **"let's vibe"** to open a fresh sign-in
590
- 2. Finish the GitHub sign-in in that tab, then come back
591
-
592
- **Still stuck?** Email seth@slashvibe.dev โ€” happy to get you in.`
554
+ display: `${welcomeBanner}\n\nCould not start sign-in (${error?.message || error}). Say vibe start to try again.`,
555
+ data: { state: 'auth_error', next: 'vibe start' },
556
+ structured: { state: 'auth_error', next: 'vibe start' },
593
557
  };
594
558
  }
559
+ return authRequiredResult(flow, welcomeBanner);
595
560
  }
596
561
 
597
562
  // ===========================================
@@ -684,4 +649,4 @@ _Say "vibe onboarding" anytime to check your progress_`
684
649
  };
685
650
  }
686
651
 
687
- module.exports = { definition, handler };
652
+ module.exports = { definition, handler, _resetPendingAuth, _forceExpireForTest, AUTH_SENTENCE };
package/tools/reply.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * vibe reply โ€” Quick reply to the most recent unread message
2
+ * vibe reply โ€” reply to a specific message by its visible #id (linked), or continue a named thread unlinked; never guesses the newest.
3
3
  *
4
4
  * Streamlined flow: one command instead of inbox โ†’ open โ†’ dm
5
5
  */
@@ -14,7 +14,7 @@ const { actions, formatActions } = require('./_actions');
14
14
 
15
15
  const definition = {
16
16
  name: 'vibe_reply',
17
- description: 'Quick reply to your most recent unread message, or to a specific person. Streamlined: one command instead of inbox โ†’ open โ†’ dm.',
17
+ description: 'Reply to a specific message by its visible #id (reply_to) โ€” the reply is linked to exactly that message. Or continue a named thread unlinked with `to`. Never guesses: with neither, it lists the visible ids instead of sending.',
18
18
  inputSchema: {
19
19
  type: 'object',
20
20
  properties: {
@@ -24,7 +24,7 @@ const definition = {
24
24
  },
25
25
  to: {
26
26
  type: 'string',
27
- description: 'Optional: reply to a specific person (e.g., @alex). If not provided, replies to most recent unread.'
27
+ description: 'Optional: continue a named thread unlinked (e.g., @alex). With neither `to` nor `reply_to`, nothing is sent โ€” the visible #ids are listed instead.'
28
28
  },
29
29
  reply_to: {
30
30
  type: 'string',
@@ -49,38 +49,48 @@ async function handler(args) {
49
49
  let targetHandle;
50
50
  let threadContext = null;
51
51
 
52
- // If specific recipient provided, use that
52
+ // THE TARGET IS NAMED, NEVER GUESSED (first-five-minutes repair). A reply
53
+ // consumes the visible #id exactly; `to` alone continues a named thread
54
+ // unlinked; nothing at all is refused WITH the candidates โ€” the old
55
+ // "first unread thread wins" default was the guess this verb's own schema
56
+ // forbids.
53
57
  if (to) {
54
58
  targetHandle = normalizeHandle(to);
55
- } else {
56
- // Find most recent unread thread
57
- const threads = await store.getInbox(myHandle);
58
-
59
- if (!threads || threads.length === 0) {
59
+ } else if (reply_to) {
60
+ // Resolve the thread that holds this id from the inbox (newest message
61
+ // per thread is served with its id); fall back to a bounded scan of the
62
+ // most recent threads for an older id.
63
+ const threads = (await store.getInbox(myHandle)) || [];
64
+ let owner = threads.find((t) => t.lastMessageId === reply_to);
65
+ if (!owner) {
66
+ for (const t of threads.slice(0, 5)) {
67
+ try {
68
+ const full = await store.getThread(myHandle, t.handle);
69
+ if ((full || []).some((m) => m.id === reply_to)) { owner = t; break; }
70
+ } catch {}
71
+ }
72
+ }
73
+ if (!owner) {
60
74
  return {
61
- display: '๐Ÿ“ญ No messages to reply to.\n\nUse `vibe dm @someone "message"` to start a conversation.',
62
- actions: formatActions(actions.recommendedConnections([]))
75
+ display: `Not sent โ€” no message #${reply_to} in your recent threads, and I won't guess. Say \`vibe inbox\` to see the ids.`,
63
76
  };
64
77
  }
65
-
66
- // Find first thread with unread messages
67
- const unreadThread = threads.find(t => t.unread > 0);
68
-
69
- if (!unreadThread) {
70
- // No unread, show most recent thread
71
- // "--to" was CLI-flag syntax for a flag that doesn't exist โ€” the actual
72
- // way to continue a read thread is a plain dm.
73
- const mostRecent = threads[0];
78
+ targetHandle = owner.handle;
79
+ } else {
80
+ const threads = (await store.getInbox(myHandle)) || [];
81
+ if (threads.length === 0) {
74
82
  return {
75
- display: `๐Ÿ“ญ nothing unread. Most recent thread: @${mostRecent.handle}\n\n_continue it with_ \`vibe dm @${mostRecent.handle} "your message"\``,
76
- actions: formatActions(actions.afterInboxCompact([{ handle: mostRecent.handle, unread: 0 }]))
83
+ display: '๐Ÿ“ญ No messages to reply to.\n\nUse `vibe dm @someone "message"` to start a conversation.',
84
+ actions: formatActions(actions.recommendedConnections([]))
77
85
  };
78
86
  }
79
-
80
- targetHandle = unreadThread.handle;
81
- threadContext = {
82
- unreadCount: unreadThread.unread,
83
- preview: unreadThread.lastMessage ? truncate(unreadThread.lastMessage, 50) : null
87
+ const candidates = threads
88
+ .filter((t) => t.lastMessageId)
89
+ .slice(0, 6)
90
+ .map((t) => ` #${t.lastMessageId} โ€” @${t.handle}${t.unread ? ` (${t.unread} unread)` : ''}: "${truncate(t.lastMessage || '', 48)}"`)
91
+ .join('\n');
92
+ return {
93
+ display: `Not sent โ€” which message are you answering? I won't pick the newest for you.\n${candidates || ' (no ids in the loaded inbox)'}\n\n_reply exactly with_ reply_to: "<id>" _โ€” or continue a thread unlinked with_ to: "@handle"`,
84
94
  };
85
95
  }
86
96
 
@@ -222,7 +232,10 @@ async function handler(args) {
222
232
  const nextHandle = stillUnread[0].handle;
223
233
  const totalUnread = stillUnread.reduce((sum, t) => sum + t.unread, 0);
224
234
  display += `\n\n๐Ÿ“ฌ ${totalUnread} more unread from ${stillUnread.map(t => `@${t.handle}`).slice(0, 3).join(', ')}`;
225
- display += `\n_Say \`vibe reply "message"\` to reply to @${nextHandle}_`;
235
+ const nextId = stillUnread[0].lastMessageId;
236
+ display += nextId
237
+ ? `\n_Reply to @${nextHandle} exactly: \`vibe reply\` with reply_to: "${nextId}"_`
238
+ : `\n_Open @${nextHandle}'s thread with \`vibe inbox @${nextHandle}\` to reply to a specific message_`;
226
239
  }
227
240
 
228
241
  // Build response with actions
package/tools/start.js CHANGED
@@ -190,11 +190,9 @@ const definition = {
190
190
  };
191
191
 
192
192
  async function handler(args) {
193
- // Step 0: Auto-update check (runs git pull if behind)
194
- const updateResult = await autoUpdate();
195
-
196
- // Step 1: Check if properly authenticated with OAuth
197
- // If not, redirect to init for GitHub auth flow (shows pre-auth banner + OAuth)
193
+ // Step 0: signed out? Go straight to the (non-blocking) sign-in. The
194
+ // auto-update git work below can take tens of seconds and must never sit
195
+ // between a stranger and their first actionable output (review P1).
198
196
  if (!config.hasOAuth()) {
199
197
  return init.handler({
200
198
  handle: args.handle,
@@ -202,6 +200,9 @@ async function handler(args) {
202
200
  });
203
201
  }
204
202
 
203
+ // Step 1: Auto-update check (runs git pull if behind) โ€” signed-in only
204
+ const updateResult = await autoUpdate();
205
+
205
206
  // Step 2: User is authenticated - show dashboard
206
207
  const myHandle = config.getHandle();
207
208
  let threads = [];
package/version.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.8.19",
3
- "updated": "2026-08-29",
4
- "changelog": "The runtime now answers four verbs \u2014 remember, reflect, message, call \u2014 and tells the truth about every one of them. vibe_capabilities reports each capability as granted, available, off, or unavailable with its reason (visible even before sign-in); discovery reads no private data and executes nothing. vibe_remember consults your own local memory with a grant or reports its state without one; vibe_reflect reveals your local VibeStats archetype (local-only, never published); vibe_call drafts an explicit live-room handoff that only you can send. Nothing sends, joins, or publishes on its own.",
2
+ "version": "0.8.20",
3
+ "updated": "2026-08-31",
4
+ "changelog": "Signing in no longer makes you wait. Say vibe start and you get the login link immediately with one sentence telling you what to do \u2014 no hung command, no blank screen, and it works over SSH or anywhere a browser cannot open. Finish in the browser whenever you like; the next vibe start picks it up. And replies now name their target: every thread in your inbox shows the id of its newest message, and vibe reply answers that exact message instead of guessing the newest one.",
5
5
  "features": [
6
- "vibe_capabilities: the truthful local manifest \u2014 remember/reflect/message/call as granted/available/off/unavailable, discovery existence-only",
7
- "vibe_remember: one sourced offer or silence with a Personal Mind grant; the honest capability state without (vibe_mind name retired)",
8
- "vibe_reflect: your local VibeStats reveal (archetype + cadence + the provider's own provenance); reflection/no-data/provider-failure/unavailable stay distinct",
9
- "vibe_call: drafts the live-room handoff line for YOU to approve and send; never sends, joins, or notifies",
10
- "Local-only verbs carry no ambient footer and their inputs are never retained"
6
+ "vibe start signed out returns immediately with the login URL and one instruction \u2014 never blocks on the browser",
7
+ "Works headless: over SSH or with no browser, the link IS the next step (and it says so honestly)",
8
+ "Sign-in completes in the background; the next vibe start recognizes it and unlocks the full toolset",
9
+ "vibe inbox shows each thread's newest message id (#msg_\u2026) so a reply can name it exactly",
10
+ "vibe reply answers that exact message \u2014 with no target it lists the ids instead of guessing"
11
11
  ],
12
12
  "deprecated": [],
13
13
  "breaking": false,