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 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
- const initResult = await initTool.handler(params.arguments || {});
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.22",
3
+ "version": "0.8.24",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
- "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) \u2014 the /vibe kernel",
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 runs a guest message poll every 15 seconds. When guest messages
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 guestPollInterval = null;
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
- // Guest message poll: every 15 seconds, check for incoming session messages
34
- // and emit tools/list_changed to force Claude to pick them up via getPresenceFooter
35
- if (!guestPollInterval) {
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 (guestPollInterval) {
46
- clearInterval(guestPollInterval);
47
- guestPollInterval = null;
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
- // Ordinary DMs: nudge the session when the unread count RISES, so a DM
117
- // sent from any surface reaches the live session within one poll cycle
118
- // (getPresenceFooter injects the new bodies, once each). A drop just
119
- // rebaselines — reading elsewhere shouldn't trigger a refresh here.
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 — guest polling is best-effort
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
- const response = await fetch(`${API_BASE}/api/presence`);
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 active = data.active || [];
332
- const away = data.away || [];
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
- ...active.slice(0, 5).map(u => ({
337
- handle: u.username || u.handle,
338
- status: 'active',
339
- one_liner: u.one_liner || u.status || ''
340
- })),
341
- ...away.slice(0, 2).map(u => ({
342
- handle: u.username || u.handle,
343
- status: 'away',
344
- one_liner: u.one_liner || u.status || ''
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(`${colors.dim} → Connected! ${onlineCount} builders online${colors.reset}`);
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} builders vibing now:${colors.reset}`);
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. Send a real message to someone you already know — "dm @their-handle ..."${colors.reset}`);
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.22",
3
- "updated": "2026-08-31",
4
- "changelog": "The first screen. vibe start is now seven lines: who you are, what's waiting, how many others are here, and where to go next. No message bodies, no chosen person, no drafted DM, no rotating tips. And it only says what it actually read — if your inbox or the room can't be reached it says so, instead of showing you a confident zero.",
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
- "vibe start is seven lines and states the unread count exactly once",
7
- "A failed read is never rendered as an empty inbox or an empty room",
8
- "No message bodies on the first screen, and nobody is chosen for you",
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,