slashvibe-mcp 0.8.6 → 0.8.7

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/auth-store.js CHANGED
@@ -26,6 +26,10 @@ let _hydrated = false;
26
26
  // unlocking a tool on the strength of a filename is how a green dot comes to mean
27
27
  // nothing (issues #107, #110).
28
28
  let _verified = false;
29
+ // We held a credential and could not tell whose it was. Distinct from "no credential":
30
+ // signed out is an honest state, but a rejected token means the name on disk is not
31
+ // backed by anything, and nothing may present it as the current identity (#107/#110).
32
+ let _rejected = false;
29
33
 
30
34
  /**
31
35
  * Hydrate auth state from disk (call once at MCP startup)
@@ -83,6 +87,7 @@ function hydrate() {
83
87
  // A token we cannot attribute is one we must not act under.
84
88
  console.error('[auth-store] Token carries no identity — treating as signed out');
85
89
  _token = null;
90
+ _rejected = true;
86
91
  } else if (_token && stored && stored !== _handle) {
87
92
  console.error(
88
93
  `[auth-store] Stored handle @${stored} does not own this session — acting as @${_handle}`
@@ -112,12 +117,13 @@ function setToken(token, { verified = false } = {}) {
112
117
  // Same rule hydrate() applies, applied here too — a credential we cannot
113
118
  // attribute must not become the session.
114
119
  console.error('[auth-store] Refusing a token with no identity');
115
- _token = null; _handle = null; _verified = false;
120
+ _token = null; _handle = null; _verified = false; _rejected = true;
116
121
  return;
117
122
  }
118
123
  }
119
124
 
120
125
  const hadToken = !!_token;
126
+ _rejected = false;
121
127
  _token = token;
122
128
  _verified = !!token && verified;
123
129
 
@@ -152,6 +158,25 @@ function getToken() {
152
158
  * @param {string} handle - User handle (without @)
153
159
  */
154
160
  function setHandle(handle) {
161
+ // A handle may be REMEMBERED, never asserted over a credential.
162
+ //
163
+ // `vibe_init` called this immediately after setToken(), passing the callback's separate
164
+ // `handle` field — so the identity derived from the token was overwritten by a value
165
+ // that merely travelled alongside it. When they agree nothing happens; when they
166
+ // disagree the client acts as one principal and displays another, which is issue #107
167
+ // recreated at the exact moment a new person signs in.
168
+ //
169
+ // tests/identity-binding.test.js did not catch it: it asserts setToken() AFTER
170
+ // setHandle(), where the token re-anchors. The real order is the reverse.
171
+ if (_token) {
172
+ const owner = handleFromToken(_token);
173
+ if (owner && handle && handle !== owner) {
174
+ console.error(
175
+ `[auth-store] Ignoring handle @${handle} — this credential belongs to @${owner}`
176
+ );
177
+ return;
178
+ }
179
+ }
155
180
  _handle = handle;
156
181
  }
157
182
 
@@ -163,6 +188,52 @@ function getHandle() {
163
188
  return _handle;
164
189
  }
165
190
 
191
+ /**
192
+ * Did we hold a credential we could not attribute?
193
+ *
194
+ * "Signed out" and "signed in as nobody" look identical from `getHandle()` — both null.
195
+ * They are not the same fact, and callers that fall back to a remembered name need to
196
+ * tell them apart: with no credential the name on disk is the best we have, but with a
197
+ * REJECTED one it is a name nothing supports (#110).
198
+ *
199
+ * @returns {boolean}
200
+ */
201
+ function hasRejectedCredential() {
202
+ return _rejected;
203
+ }
204
+
205
+ /**
206
+ * Is a stored credential actually usable right now?
207
+ *
208
+ * "A token exists" and "you are signed in" are different facts, and conflating them is
209
+ * how `npx slashvibe-mcp` came to answer "/vibe is configured" on a machine holding a
210
+ * credential that had expired 23 days earlier — then advise restarting the coding agent,
211
+ * which cannot help. The file existed, so the check passed.
212
+ *
213
+ * Local and cheap: decodes `exp` rather than asking the server, because this runs on the
214
+ * recovery path where the network may be the thing that is wrong. It cannot prove a token
215
+ * is GOOD — only the server can — but it reliably catches the case that actually stranded
216
+ * someone, which is a credential that is definitively over.
217
+ *
218
+ * @returns {{ ok: boolean, reason: 'none'|'unattributable'|'expired'|'usable', handle: string|null, expiresAt: number|null }}
219
+ */
220
+ function inspectToken(token) {
221
+ if (!token) return { ok: false, reason: 'none', handle: null, expiresAt: null };
222
+ const who = handleFromToken(token);
223
+ if (!who) return { ok: false, reason: 'unattributable', handle: null, expiresAt: null };
224
+ let exp = null;
225
+ try {
226
+ const part = String(token).split('.')[1];
227
+ exp = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')).exp ?? null;
228
+ } catch { exp = null; }
229
+ // No exp claim is not a failure — legacy tokens carry none, and refusing them here
230
+ // would sign people out to fix a display bug.
231
+ if (exp && exp * 1000 <= Date.now()) {
232
+ return { ok: false, reason: 'expired', handle: who, expiresAt: exp * 1000 };
233
+ }
234
+ return { ok: true, reason: 'usable', handle: who, expiresAt: exp ? exp * 1000 : null };
235
+ }
236
+
166
237
  /**
167
238
  * Set one-liner (what user is building)
168
239
  * @param {string} oneLiner - One-liner description
@@ -210,6 +281,16 @@ function clear() {
210
281
  _handle = null;
211
282
  _oneLiner = null;
212
283
  _verified = false;
284
+ _rejected = false;
285
+ // Clearing must also forget that we ever loaded, or `hydrate()` returns early forever
286
+ // and the store stays empty while disk holds a perfectly good credential. Everything
287
+ // that reads identity then falls through to the file — which is issue #107 reappearing
288
+ // by a different route, and silently, since the file usually names the right person.
289
+ //
290
+ // NOTE for whoever wires logout to this: clearing memory is not signing out. Disk is
291
+ // re-read on the next question, so a logout must also remove the credential from
292
+ // config.json — otherwise the session comes straight back.
293
+ _hydrated = false;
213
294
  console.error('[auth-store] Cleared');
214
295
  }
215
296
 
@@ -233,6 +314,8 @@ module.exports = {
233
314
  getToken,
234
315
  setHandle,
235
316
  getHandle,
317
+ hasRejectedCredential,
318
+ inspectToken,
236
319
  setOneLiner,
237
320
  getOneLiner,
238
321
  isAuthenticated,
package/cli.js CHANGED
@@ -19,12 +19,38 @@ if (args.includes('setup')) {
19
19
  } else if (process.stdin.isTTY) {
20
20
  // Running from terminal (not as MCP server)
21
21
  const config = require('./config');
22
- if (!config.isInitialized() && !config.getAuthToken()) {
23
- // Not authenticated — auto-trigger setup
22
+ const authStore = require('./auth-store');
23
+
24
+ // A TOKEN EXISTING IS NOT BEING SIGNED IN.
25
+ //
26
+ // This used to pass on `config.getAuthToken()` being truthy, which is true of a
27
+ // credential that expired months ago. On a Mac Studio holding a token 23 days dead it
28
+ // printed "/vibe is configured" and suggested restarting the coding agent — advice that
29
+ // cannot help, given to someone whose only real problem was that they were signed out.
30
+ // The recovery path told them there was nothing to recover.
31
+ const cred = authStore.inspectToken(config.getAuthToken());
32
+
33
+ if (!config.isInitialized() && !cred.ok) {
34
+ // Never signed in — set up.
35
+ require('./setup.js');
36
+ } else if (!cred.ok && cred.reason !== 'none') {
37
+ // Signed in once, not any more. Say which, and fix it rather than describing it.
38
+ const when = cred.expiresAt
39
+ ? ` (it expired ${Math.max(1, Math.round((Date.now() - cred.expiresAt) / 86400000))} days ago)`
40
+ : '';
41
+ console.log(cred.handle
42
+ ? `Your /vibe sign-in for @${cred.handle} is no longer valid${when}.`
43
+ : `Your /vibe sign-in is no longer valid${when}.`);
44
+ console.log('Signing you back in...');
45
+ console.log('');
24
46
  require('./setup.js');
25
47
  } else {
26
- // Already set up — show status
27
- console.log('/vibe is configured. Restart your coding agent (Claude Code / Codex / Cursor) to connect.');
48
+ // Genuinely signed in — say WHO, because a status line that cannot name you is the
49
+ // one that hid this in the first place.
50
+ console.log(cred.handle
51
+ ? `/vibe is configured — signed in as @${cred.handle}.`
52
+ : '/vibe is configured.');
53
+ console.log('Restart your coding agent (Claude Code / Codex / Cursor) to connect.');
28
54
  console.log('');
29
55
  console.log('Commands:');
30
56
  console.log(' npx slashvibe-mcp setup — re-run setup wizard');
package/config.js CHANGED
@@ -116,10 +116,25 @@ function getHandle() {
116
116
  //
117
117
  // Required lazily: auth-store loads config during hydration, and a top-level import
118
118
  // here would close that circle.
119
+ //
120
+ // We hydrate here rather than assuming someone already did. The store answers from
121
+ // memory, so before hydration it answers null — and this function would quietly fall
122
+ // through to the file, which is the exact bug above. That made the fix depend on an
123
+ // ordering nothing enforced: correct inside the MCP server (which hydrates in its
124
+ // constructor) and wrong in any script, CLI path or test that reads identity first.
125
+ // A guarantee that holds only when called in the right order is not a guarantee.
126
+ // hydrate() is idempotent and reads two files it has usually already read.
119
127
  try {
120
128
  const authStore = require('./auth-store');
129
+ authStore.hydrate();
121
130
  const fromCredential = authStore.getHandle();
122
131
  if (fromCredential) return fromCredential;
132
+
133
+ // A credential was present and could not be attributed. The remembered name below
134
+ // must NOT step in: it would put a handle on screen that nothing backs, which is
135
+ // this bug wearing the file's clothes. No credential at all is a different and
136
+ // honest state, and falls through.
137
+ if (authStore.hasRejectedCredential()) return null;
123
138
  } catch (e) {
124
139
  // auth-store unavailable (early boot) — fall through to what is on disk.
125
140
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
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/setup.js CHANGED
@@ -274,8 +274,23 @@ async function testConnection() {
274
274
  // HTTP already answers; the body is only allowed to veto by reporting an explicit
275
275
  // unhealthy state.
276
276
  // Bounded: a hung connection must not leave a new user staring at a spinner with no
277
- // way to know whether to wait. And "degraded" is not good enough to promise the next
278
- // step will work — the very next thing setup does is an OAuth round trip.
277
+ // way to know whether to wait.
278
+ //
279
+ // "DEGRADED" IS NOT UNREACHABLE, and treating it as such blocked every new install on
280
+ // 2026-08-01. Upstash hit a quota wall, /api/health honestly reported `degraded`
281
+ // because a CACHE was down, and this returned false — so setup told people "Could not
282
+ // reach slashvibe.dev" and advised them to check their internet connection, about a
283
+ // site that was serving perfectly.
284
+ //
285
+ // The reasoning in the version that did this was mine and it was wrong: I blocked on
286
+ // degraded because "the next step is an OAuth round trip". OAuth does not touch the
287
+ // cache. A partial-capability signal about a subsystem we do not need is not a reason
288
+ // to refuse someone the product, and "we cannot reach the server" is not a true thing
289
+ // to say when we just did.
290
+ //
291
+ // Only an explicitly unhealthy service blocks. Everything else proceeds, because the
292
+ // OAuth round trip that follows is a better test of what setup actually needs than any
293
+ // status string.
279
294
  const controller = new AbortController();
280
295
  const timer = setTimeout(() => controller.abort(), 8000);
281
296
  try {
@@ -283,7 +298,7 @@ async function testConnection() {
283
298
  if (!response.ok) return false;
284
299
  const data = await response.json().catch(() => ({}));
285
300
  if (data.success === false) return false;
286
- if (typeof data.status === 'string' && /unhealthy|degraded|down|error/i.test(data.status)) return false;
301
+ if (typeof data.status === 'string' && /\bunhealthy\b|\bdown\b/i.test(data.status)) return false;
287
302
  return true;
288
303
  } catch (e) {
289
304
  return false;
package/store/api.js CHANGED
@@ -355,6 +355,28 @@ async function getActiveUsers() {
355
355
  const liveAgents = (result.agents || []).filter(a => a.isLive && a.broadcastRoom);
356
356
  const users = [...(result.active || []), ...(result.away || []), ...liveAgents];
357
357
 
358
+ // SIGNED OUT IS NOT AN EMPTY ROOM.
359
+ //
360
+ // When the caller has no valid credential the server answers `anonymous: true` with
361
+ // empty lists and PUBLIC COUNTS — "2 people are here, sign in to see who". This
362
+ // function used to return only the mapped array, so every caller saw `[]` and could
363
+ // not tell the two apart. `vibe_who` rendered it as "Quiet right now — you're the
364
+ // only one here", which is the most damaging sentence this product can say: the
365
+ // entire promise is that other people are there, and a signed-out user was told the
366
+ // room was dead. Observed on the Mac Studio 2026-08-01 with a token that had expired
367
+ // 23 days earlier, while four people were actually online.
368
+ //
369
+ // Attached to the array rather than changing the return type because sixteen call
370
+ // sites read this as a list. Callers that do not care are unaffected; callers that
371
+ // render a claim about the room now have what they need to be honest.
372
+ // Two shapes mean the same thing. v1 answers 200 with `anonymous: true` and public
373
+ // COUNTS; v2 answers a hard 401 and no counts at all. Both are "we cannot see who is
374
+ // here", and neither is "nobody is here" — so both must reach the caller as the same
375
+ // fact, with counts present only when the server actually supplied them. Claiming
376
+ // "0 people are here" because we lack a number would swap one false statement for
377
+ // another.
378
+ const unauthenticated = result?.anonymous === true || result?.statusCode === 401;
379
+
358
380
  // Map to normalized format (v2 uses 'handle', v1 uses 'username')
359
381
  const mappedUsers = users.map(u => ({
360
382
  handle: u.handle || u.username,
@@ -400,6 +422,14 @@ async function getActiveUsers() {
400
422
  // Non-fatal: profiles module may not be available in some contexts
401
423
  }
402
424
 
425
+ // On the RETURNED array, not the intermediate `users` — they are different objects
426
+ // and attaching to the wrong one is a silent no-op that reads as "the signal is not
427
+ // there" rather than as a bug.
428
+ Object.defineProperties(mappedUsers, {
429
+ anonymous: { value: unauthenticated, enumerable: false },
430
+ counts: { value: result?.counts || null, enumerable: false },
431
+ });
432
+
403
433
  return mappedUsers;
404
434
  } catch (e) {
405
435
  console.error('Who failed:', e.message);
package/tools/start.js CHANGED
@@ -291,23 +291,15 @@ async function handler(args) {
291
291
  }
292
292
  } catch (e) {}
293
293
 
294
- // Step 4: Get connection suggestions from API
295
- let suggestions = [];
296
- try {
297
- const apiUrl = config.getApiUrl();
298
- const suggestionsResponse = await fetch(`${apiUrl}/api/suggestions?user=${myHandle}&limit=3`, {
299
- headers: { 'User-Agent': 'vibe-mcp-client' }
300
- });
301
-
302
- if (suggestionsResponse.ok) {
303
- const data = await suggestionsResponse.json();
304
- if (data.success && data.suggestions) {
305
- suggestions = data.suggestions;
306
- }
307
- }
308
- } catch (e) {
309
- // Silent fail - suggestions are nice-to-have
310
- }
294
+ // Step 4 used to fetch /api/suggestions and render "Suggested connections" — three
295
+ // strangers proposed on every session start, in the DEFAULT surface rather than behind
296
+ // VIBE_EXTRAS. Removed 2026-08-01 (Seth) with the automated cold intros in
297
+ // api/lib/board-service.js.
298
+ //
299
+ // Proposing people you do not know is the retired public-network product. A private
300
+ // fabric shows you the people you already work with, and says nothing when there is
301
+ // nobody to say anything about. NOTE: `workContext.suggestions` elsewhere in this file
302
+ // is unrelated — that is local work context, not people.
311
303
 
312
304
  // Generate the ASCII welcome card (matches init.js format)
313
305
  const welcomeCard = generateWelcomeCard({
@@ -380,17 +372,6 @@ async function handler(args) {
380
372
  display += `\n_Session sharing active. Use vibe_guest to exchange messages._`;
381
373
  }
382
374
 
383
- // Add connection suggestions (if any)
384
- if (suggestions.length > 0) {
385
- display += `\n\n**🤝 Suggested connections:**`;
386
- suggestions.slice(0, 3).forEach(s => {
387
- display += `\n• @${s.handle} — ${s.reason}`;
388
- if (s.github_name) {
389
- display += ` (${s.github_name})`;
390
- }
391
- });
392
- }
393
-
394
375
  // Add memory context for returning users
395
376
  if (threads.length > 0) {
396
377
  const recentThreads = threads.slice(0, 3);
@@ -454,16 +435,6 @@ async function handler(args) {
454
435
  };
455
436
  }
456
437
 
457
- // Include connection suggestions for smart discovery
458
- if (suggestions.length > 0) {
459
- response.suggestions = suggestions.map(s => ({
460
- handle: s.handle,
461
- reason: s.reason,
462
- githubName: s.github_name || null,
463
- lastActive: s.last_active || null,
464
- matchScore: s.score || null
465
- }));
466
- }
467
438
 
468
439
  // Determine session state and suggest appropriate flow
469
440
  let suggestion = null;
package/tools/who.js CHANGED
@@ -178,6 +178,30 @@ async function handler(args) {
178
178
  // Check for notifications (presence + messages)
179
179
  notify.checkAll(store);
180
180
 
181
+ // TWO EMPTY STATES, AND THEY ARE NOT THE SAME FACT.
182
+ //
183
+ // An empty list can mean "nobody is here" or "we cannot see" — and only one of those
184
+ // is about the room. A signed-out session used to get the first message while four
185
+ // people were online, which tells someone the product is dead at the exact moment they
186
+ // are deciding whether it works.
187
+ if (rawUsers.anonymous) {
188
+ // Say the number only when the server gave us one. v1 supplies public counts; v2
189
+ // answers 401 with nothing, and inventing "0 people" there would be the same lie in
190
+ // a different costume.
191
+ const n = rawUsers.counts?.active;
192
+ const line = typeof n === 'number'
193
+ ? `_You're signed out — ${n === 1 ? '1 person is' : `${n} people are`} here right now, but names need a sign-in._`
194
+ : `_You're signed out, so I can't see who's here._`;
195
+ return {
196
+ structured: { generatedAt: Date.now(), users: [], signedOut: true, counts: rawUsers.counts || null },
197
+ display: `## Who's Around
198
+
199
+ ${line}
200
+
201
+ Run \`vibe init\` and you'll see who, and they'll see you.`
202
+ };
203
+ }
204
+
181
205
  if (users.length === 0) {
182
206
  return {
183
207
  structured: { generatedAt: Date.now(), users: [] },
package/version.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
- "version": "0.8.6",
3
- "updated": "2026-07-31",
4
- "changelog": "Who is here is no longer public. Counts are; handles are not \u2014 sign in to see names, which every normal install already does. The rest is the first ninety minutes: an empty inbox now says what to do next instead of 'all caught up' to someone who never had a message, the board stops advertising tools your session does not have, and a failed message names one fix instead of three contradictory ones.",
2
+ "version": "0.8.7",
3
+ "updated": "2026-08-01",
4
+ "changelog": "Three ways /vibe told you something untrue, all on the path a new person walks. Installing failed with \"Could not reach slashvibe.dev\" whenever a cache was over quota — the site was serving fine, and a subsystem setup never uses was refusing you the product. Re-running setup on a machine whose login had expired answered \"/vibe is configured\" and quit, so the one command for fixing it said there was nothing to fix. And asking who was around while signed out reported an empty room instead of saying you were signed out, which is the worst thing this product can get wrong. Logins are now 30 days everywhere; new accounts had been getting one-hour ones.",
5
5
  "features": [
6
- "Presence names require a credential; counts stay public",
7
- "Empty inbox tells you what to do next, and knows arriving from caught-up",
8
- "The board only suggests commands your session actually has",
9
- "Errors name one fix and stop"
6
+ "A degraded cache no longer blocks installing",
7
+ "An expired login says so, names the account, and signs you back in",
8
+ "Signed out reads as signed out, not as an empty room",
9
+ "One login lifetime — 30 days, including for brand-new accounts"
10
10
  ],
11
11
  "deprecated": [],
12
12
  "breaking": false,