slashvibe-mcp 0.8.23 → 0.8.25
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 +1 -0
- package/package.json +1 -1
- package/presence.js +77 -18
- package/protocol/handle.js +6 -4
- package/store/api.js +46 -17
- package/version.json +6 -7
package/index.js
CHANGED
|
@@ -658,6 +658,7 @@ class VibeMCPServer {
|
|
|
658
658
|
// The HTTP store creates one random key per logical send and preserves it
|
|
659
659
|
// across its internal transport retries. JSON-RPC ids restart every MCP
|
|
660
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
|
|
661
662
|
const result = await tool.handler(args);
|
|
662
663
|
|
|
663
664
|
// Emit list_changed notification for state-changing tools
|
package/package.json
CHANGED
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/protocol/handle.js
CHANGED
|
@@ -30,12 +30,14 @@ function canonicalHandle(value) {
|
|
|
30
30
|
if (typeof value !== 'string') return '';
|
|
31
31
|
let h = value.replace(INVISIBLE, '');
|
|
32
32
|
h = h.normalize ? h.normalize('NFC') : h;
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A handle is the GitHub login, and GitHub logins may contain '-'. Rewriting
|
|
34
|
+
// dashes to underscores made `vibe inbox synth-stan` look up synth_stan and
|
|
35
|
+
// render an empty thread (found live 2026-09-02). Keep the dash.
|
|
36
|
+
return h.trim().replace(/^@+/, '').trim().toLowerCase();
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/** Platform-legal shape: ASCII letters, digits, underscore. Reject anything else. */
|
|
38
|
-
const isCanonicalHandle = (value) => /^[a-z0-9_]{1,39}$/.test(canonicalHandle(value));
|
|
39
|
+
/** Platform-legal shape: ASCII letters, digits, underscore, dash (GitHub logins). Reject anything else. */
|
|
40
|
+
const isCanonicalHandle = (value) => /^[a-z0-9_-]{1,39}$/.test(canonicalHandle(value));
|
|
39
41
|
|
|
40
42
|
/** True when two handles refer to the same principal label. */
|
|
41
43
|
const sameHandle = (a, b) => {
|
package/store/api.js
CHANGED
|
@@ -797,24 +797,53 @@ async function getRawInbox(handle) {
|
|
|
797
797
|
|
|
798
798
|
// Transform threads with unread messages into message-like objects
|
|
799
799
|
// for compatibility with notification system
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
800
|
+
const me = String(handle || '').toLowerCase();
|
|
801
|
+
const isMine = (m) => String(m?.from || '').toLowerCase() === me;
|
|
802
|
+
const candidates = threads.filter((t) => t.unread > 0 && t.last_message);
|
|
803
|
+
|
|
804
|
+
// "Waiting" means THEIR words. When the newest message is mine — I replied
|
|
805
|
+
// and their earlier message is still unread — the thread's last_message
|
|
806
|
+
// is my own send, and presenting it as "MESSAGE from @me" told a person
|
|
807
|
+
// their own words had arrived (Seth, 2026-09-02, the day of the invite).
|
|
808
|
+
// For those threads read the TAIL of the thread (the API pages oldest-first)
|
|
809
|
+
// and take the newest message of theirs. Bounded parallelism: the session-
|
|
810
|
+
// start hook runs this under a 4s deadline (codex P2 ×2 on #38).
|
|
811
|
+
const TAIL = 50;
|
|
812
|
+
const resolveWaiting = async (thread) => {
|
|
813
|
+
if (!isMine(thread.last_message)) return thread.last_message;
|
|
814
|
+
try {
|
|
815
|
+
const count = Number(thread.message_count);
|
|
816
|
+
const offset = Number.isFinite(count) && count > TAIL ? count - TAIL : 0;
|
|
817
|
+
const t = await request('GET', `/api/v2/threads/${encodeURIComponent(thread.id)}?limit=${TAIL}&offset=${offset}`);
|
|
818
|
+
const theirs = (t.messages || []).filter((m) => !isMine(m));
|
|
819
|
+
return theirs.length ? theirs[theirs.length - 1] : null;
|
|
820
|
+
} catch {
|
|
821
|
+
return null;
|
|
816
822
|
}
|
|
817
|
-
}
|
|
823
|
+
};
|
|
824
|
+
const CONCURRENCY = 6;
|
|
825
|
+
const resolved = new Array(candidates.length);
|
|
826
|
+
let next = 0;
|
|
827
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, async () => {
|
|
828
|
+
while (next < candidates.length) { const i = next++; resolved[i] = await resolveWaiting(candidates[i]); }
|
|
829
|
+
}));
|
|
830
|
+
|
|
831
|
+
const unreadMessages = [];
|
|
832
|
+
candidates.forEach((thread, i) => {
|
|
833
|
+
const waiting = resolved[i];
|
|
834
|
+
if (!waiting) return; // nothing of theirs to show — not a waiting message
|
|
835
|
+
unreadMessages.push({
|
|
836
|
+
id: waiting.id,
|
|
837
|
+
from: waiting.from,
|
|
838
|
+
to: handle,
|
|
839
|
+
text: waiting.body,
|
|
840
|
+
body: waiting.body,
|
|
841
|
+
createdAt: waiting.created_at,
|
|
842
|
+
read: false, // If it's in unread threads, it's unread
|
|
843
|
+
thread_id: thread.id,
|
|
844
|
+
unread_count: thread.unread,
|
|
845
|
+
});
|
|
846
|
+
});
|
|
818
847
|
|
|
819
848
|
return unreadMessages;
|
|
820
849
|
} catch (e) {
|
package/version.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
3
|
-
"updated": "2026-09-
|
|
4
|
-
"changelog": "
|
|
2
|
+
"version": "0.8.25",
|
|
3
|
+
"updated": "2026-09-02",
|
|
4
|
+
"changelog": "Waiting means their words. After you replied to someone, their next session could open with their own message echoed back as if it had just arrived; now a waiting message is always the other person's newest words. And handles keep their dashes — a GitHub login like synth-stan is looked up as written, so the thread opens instead of coming back empty.",
|
|
5
5
|
"features": [
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"Setup's quick start is a sentence you can say, not a command to learn"
|
|
6
|
+
"A waiting message at session start is always the other person's newest message, never your own reply",
|
|
7
|
+
"Handles with dashes open the right thread",
|
|
8
|
+
"Long threads read from the end, and the startup check finishes inside its time budget"
|
|
10
9
|
],
|
|
11
10
|
"deprecated": [],
|
|
12
11
|
"breaking": false,
|