slashvibe-mcp 0.8.22 → 0.8.24
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 +6 -1
- package/package.json +2 -2
- package/presence.js +77 -18
- package/setup.js +34 -22
- package/version.json +6 -8
package/index.js
CHANGED
|
@@ -595,7 +595,11 @@ class VibeMCPServer {
|
|
|
595
595
|
// vibe_init reads only {handle, one_liner, auth_method} and ignores any
|
|
596
596
|
// extra keys, so this is safe for every command yet lets relevant
|
|
597
597
|
// context (e.g. a handle the caller already supplied) carry into auth.
|
|
598
|
-
|
|
598
|
+
// The gated tool's arguments describe ITS job, not the person signing in:
|
|
599
|
+
// vibe_dm's `handle` is the recipient. Forwarding it made a first message
|
|
600
|
+
// to @brightseth request a sign-in AS @brightseth (login?handle=brightseth,
|
|
601
|
+
// config.handle saved as the recipient). GitHub says who you are; pass nothing.
|
|
602
|
+
const initResult = await initTool.handler({});
|
|
599
603
|
const initDisplay = initResult.display || JSON.stringify(initResult, null, 2);
|
|
600
604
|
|
|
601
605
|
// After auth, emit tools/list_changed so Claude sees full toolset
|
|
@@ -654,6 +658,7 @@ class VibeMCPServer {
|
|
|
654
658
|
// The HTTP store creates one random key per logical send and preserves it
|
|
655
659
|
// across its internal transport retries. JSON-RPC ids restart every MCP
|
|
656
660
|
// process, so deriving a durable key from `id` would collapse future sends.
|
|
661
|
+
presence.noteActivity(); // a tool call = the person is here; polls snap back to fast
|
|
657
662
|
const result = await tool.handler(args);
|
|
658
663
|
|
|
659
664
|
// Emit list_changed notification for state-changing tools
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slashvibe-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.24",
|
|
4
4
|
"mcpName": "io.github.vibecodinginc/vibe",
|
|
5
|
-
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor)
|
|
5
|
+
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) — the /vibe kernel",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"slashvibe-mcp": "cli.js",
|
package/presence.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Uses session tokens for per-session identity.
|
|
6
6
|
* Users become "idle" after 5 minutes of no heartbeat.
|
|
7
7
|
*
|
|
8
|
-
* Also
|
|
8
|
+
* Also polls for DMs and guest messages on an idle-aware cadence. When they
|
|
9
9
|
* are detected, emits a tools/list_changed notification to force Claude
|
|
10
10
|
* to re-query vibe tools (which triggers getPresenceFooter and injects
|
|
11
11
|
* the messages into context). This ensures Use Case 2 (user testing/QA)
|
|
@@ -18,9 +18,54 @@ const store = require('./store');
|
|
|
18
18
|
const { apiHeaders } = require('./api-auth');
|
|
19
19
|
|
|
20
20
|
let heartbeatInterval = null;
|
|
21
|
-
let
|
|
21
|
+
let unreadPollTimer = null;
|
|
22
|
+
let guestPollTimer = null;
|
|
22
23
|
let sessionInitialized = false;
|
|
23
24
|
|
|
25
|
+
// ---- Poll cadence (2026-09-02 audit) -------------------------------------
|
|
26
|
+
// One idle signed-in client made 2 inbox reads every 15s forever — session/guest
|
|
27
|
+
// (6 KV commands) + the full threads list (1) — and the fleet's long-lived
|
|
28
|
+
// sessions (9-hour and 9-day-old windows) added up to ~7 inbox polls/sec at
|
|
29
|
+
// the KV. Replies must still land fast, so the DM check stays at 15s while the
|
|
30
|
+
// person is ACTIVE (any tool call in the last ACTIVE_WINDOW_MS) and backs off
|
|
31
|
+
// to 60s when idle; the guest-session poll, a pairing feature almost nobody is
|
|
32
|
+
// in, runs at 60s active / 300s idle. Any tool call snaps both back to fast.
|
|
33
|
+
// Worst-case DM latency: 15s active, 60s idle.
|
|
34
|
+
const ACTIVE_WINDOW_MS = Number(process.env.VIBE_POLL_ACTIVE_WINDOW_MS) || 10 * 60 * 1000;
|
|
35
|
+
const CADENCE = {
|
|
36
|
+
active: { unread: 15 * 1000, guest: 60 * 1000 },
|
|
37
|
+
idle: { unread: 60 * 1000, guest: 300 * 1000 },
|
|
38
|
+
};
|
|
39
|
+
let lastActivityAt = Date.now(); // the session starting is activity
|
|
40
|
+
|
|
41
|
+
function noteActivity() {
|
|
42
|
+
const wasIdle = cadenceFor(Date.now() - lastActivityAt) === CADENCE.idle;
|
|
43
|
+
lastActivityAt = Date.now();
|
|
44
|
+
// A pending idle timer would still fire on the slow cadence (codex P2 on #37):
|
|
45
|
+
// re-arm both polls on the fast cadence the moment the person is back.
|
|
46
|
+
if (wasIdle) {
|
|
47
|
+
if (unreadPollTimer) { clearTimeout(unreadPollTimer); schedule('unread'); }
|
|
48
|
+
if (guestPollTimer) { clearTimeout(guestPollTimer); schedule('guest'); }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Pure: which cadence applies after `idleMs` without a tool call. */
|
|
53
|
+
function cadenceFor(idleMs) {
|
|
54
|
+
return idleMs < ACTIVE_WINDOW_MS ? CADENCE.active : CADENCE.idle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schedule(kind) {
|
|
58
|
+
const ms = cadenceFor(Date.now() - lastActivityAt)[kind];
|
|
59
|
+
const run = async () => {
|
|
60
|
+
if (kind === 'unread') await pollUnread(); else await pollGuestMessages();
|
|
61
|
+
// Re-arm only while start() is in effect (stop() clears the timers).
|
|
62
|
+
if (kind === 'unread' ? unreadPollTimer : guestPollTimer) schedule(kind);
|
|
63
|
+
};
|
|
64
|
+
const t = setTimeout(run, ms);
|
|
65
|
+
t.unref?.();
|
|
66
|
+
if (kind === 'unread') unreadPollTimer = t; else guestPollTimer = t;
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
function start() {
|
|
25
70
|
if (heartbeatInterval) return;
|
|
26
71
|
|
|
@@ -30,11 +75,9 @@ function start() {
|
|
|
30
75
|
// Then every 30 seconds
|
|
31
76
|
heartbeatInterval = setInterval(sendHeartbeat, 30 * 1000);
|
|
32
77
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
if (!
|
|
36
|
-
guestPollInterval = setInterval(pollGuestMessages, 15 * 1000);
|
|
37
|
-
}
|
|
78
|
+
// Inbox polls on the idle-aware cadence above (see CADENCE).
|
|
79
|
+
if (!unreadPollTimer) schedule('unread');
|
|
80
|
+
if (!guestPollTimer) schedule('guest');
|
|
38
81
|
}
|
|
39
82
|
|
|
40
83
|
function stop() {
|
|
@@ -42,9 +85,13 @@ function stop() {
|
|
|
42
85
|
clearInterval(heartbeatInterval);
|
|
43
86
|
heartbeatInterval = null;
|
|
44
87
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
if (unreadPollTimer) {
|
|
89
|
+
clearTimeout(unreadPollTimer);
|
|
90
|
+
unreadPollTimer = null;
|
|
91
|
+
}
|
|
92
|
+
if (guestPollTimer) {
|
|
93
|
+
clearTimeout(guestPollTimer);
|
|
94
|
+
guestPollTimer = null;
|
|
48
95
|
}
|
|
49
96
|
// Clean up session file
|
|
50
97
|
config.clearSession();
|
|
@@ -97,13 +144,11 @@ async function pollGuestMessages() {
|
|
|
97
144
|
try {
|
|
98
145
|
const handle = config.getHandle();
|
|
99
146
|
if (!handle) return;
|
|
100
|
-
|
|
101
147
|
const apiUrl = config.getApiUrl?.() || 'https://www.slashvibe.dev';
|
|
102
148
|
const resp = await fetch(`${apiUrl}/api/session/guest?handle=${encodeURIComponent(handle)}`, {
|
|
103
149
|
headers: apiHeaders(),
|
|
104
150
|
});
|
|
105
151
|
if (!resp.ok) return;
|
|
106
|
-
|
|
107
152
|
const data = await resp.json();
|
|
108
153
|
if (data.success && Array.isArray(data.messages) && data.messages.length > 0) {
|
|
109
154
|
// Guest messages waiting — emit list_changed to force Claude to refresh
|
|
@@ -112,11 +157,21 @@ async function pollGuestMessages() {
|
|
|
112
157
|
global.vibeNotifier.emitImmediate();
|
|
113
158
|
}
|
|
114
159
|
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Silent fail — guest polling is best-effort
|
|
162
|
+
}
|
|
163
|
+
}
|
|
115
164
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Ordinary DMs: nudge the session when the unread count RISES, so a DM sent
|
|
167
|
+
* from any surface reaches the live session within one poll cycle
|
|
168
|
+
* (getPresenceFooter injects the new bodies, once each). A drop just
|
|
169
|
+
* rebaselines — reading elsewhere shouldn't trigger a refresh here.
|
|
170
|
+
*/
|
|
171
|
+
async function pollUnread() {
|
|
172
|
+
try {
|
|
173
|
+
const handle = config.getHandle();
|
|
174
|
+
if (!handle) return;
|
|
120
175
|
const unread = await store.getUnreadCount(handle).catch(() => null);
|
|
121
176
|
if (typeof unread === 'number') {
|
|
122
177
|
if (lastSeenUnread !== null && unread > lastSeenUnread && global.vibeNotifier) {
|
|
@@ -125,7 +180,7 @@ async function pollGuestMessages() {
|
|
|
125
180
|
lastSeenUnread = unread;
|
|
126
181
|
}
|
|
127
182
|
} catch {
|
|
128
|
-
// Silent fail —
|
|
183
|
+
// Silent fail — best-effort
|
|
129
184
|
}
|
|
130
185
|
}
|
|
131
186
|
|
|
@@ -170,4 +225,8 @@ async function goOffline() {
|
|
|
170
225
|
}
|
|
171
226
|
}
|
|
172
227
|
|
|
173
|
-
module.exports = { start, stop, forceHeartbeat, goOffline
|
|
228
|
+
module.exports = { start, stop, forceHeartbeat, goOffline, noteActivity, cadenceFor, CADENCE, ACTIVE_WINDOW_MS,
|
|
229
|
+
// test seams — read the armed delays, and age the session without waiting
|
|
230
|
+
_pollDelaysForTest: () => ({ unread: unreadPollTimer?._idleTimeout ?? null, guest: guestPollTimer?._idleTimeout ?? null }),
|
|
231
|
+
_setLastActivityForTest: (ts) => { lastActivityAt = ts; },
|
|
232
|
+
};
|
package/setup.js
CHANGED
|
@@ -315,6 +315,14 @@ async function getOnlineCount() {
|
|
|
315
315
|
try {
|
|
316
316
|
const response = await fetch(`${API_BASE}/api/presence`);
|
|
317
317
|
const data = await response.json();
|
|
318
|
+
// Before sign-in the roster is private: the server answers with counts only
|
|
319
|
+
// (`{ anonymous: true, counts: { humansActive, active, ... }, active: [] }`).
|
|
320
|
+
// Counting names here printed "0 builders online" to every newcomer while
|
|
321
|
+
// eleven people were on. Read the count the server actually gives.
|
|
322
|
+
if (data.counts && typeof data.counts === 'object') {
|
|
323
|
+
const n = data.counts.humansActive ?? data.counts.active;
|
|
324
|
+
if (Number.isFinite(n)) return n;
|
|
325
|
+
}
|
|
318
326
|
return (data.active?.length || 0) + (data.away?.length || 0);
|
|
319
327
|
} catch (e) {
|
|
320
328
|
return 0;
|
|
@@ -324,28 +332,30 @@ async function getOnlineCount() {
|
|
|
324
332
|
/**
|
|
325
333
|
* Get online users with details for display
|
|
326
334
|
*/
|
|
327
|
-
async function getOnlineUsers() {
|
|
335
|
+
async function getOnlineUsers(token) {
|
|
328
336
|
try {
|
|
329
|
-
|
|
337
|
+
// Handles are only served to a signed-in caller; pass the fresh token so the
|
|
338
|
+
// "here now" list after sign-in is real, not an empty anonymous answer.
|
|
339
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
340
|
+
const response = await fetch(`${API_BASE}/api/presence`, { headers });
|
|
330
341
|
const data = await response.json();
|
|
331
|
-
const
|
|
332
|
-
|
|
342
|
+
const people = (u) => !u.isAgent;
|
|
343
|
+
// "Here now" means active humans only. Away rows were once appended below,
|
|
344
|
+
// which let the headline count disagree with the rows under it (codex P2).
|
|
345
|
+
const active = (data.active || []).filter(people);
|
|
346
|
+
const workText = (u) => u.workingOn || u.working_on || u.one_liner || '';
|
|
333
347
|
|
|
334
348
|
// Format: { users: [{handle, status, one_liner}], total: number }
|
|
335
|
-
const users =
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}))
|
|
346
|
-
].slice(0, 5);
|
|
347
|
-
|
|
348
|
-
return { users, total: active.length + away.length };
|
|
349
|
+
const users = active.slice(0, 5).map(u => ({
|
|
350
|
+
handle: u.username || u.handle,
|
|
351
|
+
status: 'active',
|
|
352
|
+
one_liner: workText(u)
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
const total = Number.isFinite(data.counts?.humansActive)
|
|
356
|
+
? data.counts.humansActive
|
|
357
|
+
: active.length;
|
|
358
|
+
return { users, total };
|
|
349
359
|
} catch (e) {
|
|
350
360
|
return { users: [], total: 0 };
|
|
351
361
|
}
|
|
@@ -477,7 +487,9 @@ async function setup() {
|
|
|
477
487
|
}
|
|
478
488
|
|
|
479
489
|
const onlineCount = await getOnlineCount();
|
|
480
|
-
console.log(
|
|
490
|
+
console.log(onlineCount > 0
|
|
491
|
+
? `${colors.dim} → Connected — ${onlineCount} ${onlineCount === 1 ? 'person' : 'people'} here now${colors.reset}`
|
|
492
|
+
: `${colors.dim} → Connected${colors.reset}`);
|
|
481
493
|
printStep(3, 'Testing connection...', 'done');
|
|
482
494
|
|
|
483
495
|
// Step 4: Authenticate
|
|
@@ -504,7 +516,7 @@ async function setup() {
|
|
|
504
516
|
console.log(`${colors.dim} → Authenticated as @${authResult.handle}${colors.reset}`);
|
|
505
517
|
|
|
506
518
|
// Success! Show who's online immediately
|
|
507
|
-
const presence = await getOnlineUsers();
|
|
519
|
+
const presence = await getOnlineUsers(authResult.token);
|
|
508
520
|
|
|
509
521
|
console.log('');
|
|
510
522
|
console.log(`${colors.green} ✓ Setup complete!${colors.reset}`);
|
|
@@ -512,7 +524,7 @@ async function setup() {
|
|
|
512
524
|
|
|
513
525
|
// Show who's vibing right now
|
|
514
526
|
if (presence.users.length > 0) {
|
|
515
|
-
console.log(`${colors.bold} 🟢 ${presence.total}
|
|
527
|
+
console.log(`${colors.bold} 🟢 ${presence.total} people here now:${colors.reset}`);
|
|
516
528
|
for (const user of presence.users) {
|
|
517
529
|
const statusIcon = user.status === 'active' ? colors.green + '●' : colors.yellow + '○';
|
|
518
530
|
const liner = user.one_liner ? ` — ${user.one_liner.slice(0, 40)}` : '';
|
|
@@ -526,7 +538,7 @@ async function setup() {
|
|
|
526
538
|
|
|
527
539
|
console.log(`${colors.bold} Quick start:${colors.reset}`);
|
|
528
540
|
console.log(`${colors.dim} 1. Restart your coding agent${detected.length > 1 ? `s (${detected.join(', ')})` : ''}${colors.reset}`);
|
|
529
|
-
console.log(`${colors.dim} 2.
|
|
541
|
+
console.log(`${colors.dim} 2. Say: "message @their-handle — ..." to someone you already know${colors.reset}`);
|
|
530
542
|
console.log(`${colors.dim} 3. Claude Code: run "npx slashvibe-mcp hook install" — waiting messages appear at the top of your next session${colors.reset}`);
|
|
531
543
|
console.log('');
|
|
532
544
|
console.log(`${colors.cyan} Welcome to /vibe, @${authResult.handle}.${colors.reset}`);
|
package/version.json
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
3
|
-
"updated": "2026-
|
|
4
|
-
"changelog": "
|
|
2
|
+
"version": "0.8.24",
|
|
3
|
+
"updated": "2026-09-02",
|
|
4
|
+
"changelog": "Quieter when you are away. The server used to check your inbox twice every fifteen seconds for as long as your coding session stayed open — days, sometimes. Now it checks every fifteen seconds while you are actually using /vibe and backs off to once a minute when you are not; the moment you use a tool it is fast again. Replies still reach a live session within fifteen seconds, and within a minute of an idle one.",
|
|
5
5
|
"features": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"A read that did not succeed can no longer authorize a write — six places where one corrupt line destroyed messages, profiles, presence or your credential",
|
|
10
|
-
"vibe token no longer says “old local keys removed” unless they are gone"
|
|
6
|
+
"Inbox checks back off to once a minute when a session is idle, and snap back the moment you use a tool",
|
|
7
|
+
"The rarely used guest-session poll runs once a minute while active and every five minutes while idle",
|
|
8
|
+
"Presence heartbeat unchanged: you stay visible exactly as before"
|
|
11
9
|
],
|
|
12
10
|
"deprecated": [],
|
|
13
11
|
"breaking": false,
|