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 +14 -9
- package/notification-emitter.js +2 -2
- package/package.json +1 -1
- package/store/api.js +4 -0
- package/tools/help.js +1 -1
- package/tools/inbox.js +15 -6
- package/tools/init.js +151 -186
- package/tools/reply.js +41 -28
- package/tools/start.js +6 -5
- package/version.json +8 -8
package/index.js
CHANGED
|
@@ -586,16 +586,21 @@ class VibeMCPServer {
|
|
|
586
586
|
global.vibeNotifier?.emitImmediate();
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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',
|
package/notification-emitter.js
CHANGED
|
@@ -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
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 "
|
|
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
|
-
|
|
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:
|
|
380
|
-
? `๐ญ nothing unread.\n\n_your recent threads:_ ${
|
|
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+:
|
|
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
|
-
|
|
482
|
-
|
|
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: `
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
572
|
-
|
|
573
|
-
|
|
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:
|
|
585
|
-
|
|
586
|
-
|
|
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 โ
|
|
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: '
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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
|
-
|
|
67
|
-
const
|
|
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:
|
|
76
|
-
actions: formatActions(actions.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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:
|
|
194
|
-
|
|
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.
|
|
3
|
-
"updated": "2026-08-
|
|
4
|
-
"changelog": "
|
|
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
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,
|