slashvibe-mcp 0.8.5 → 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/index.js CHANGED
@@ -241,7 +241,10 @@ async function getPresenceFooter() {
241
241
 
242
242
  // The status line. Counts only — no mood inference, no nudges.
243
243
  const parts = ['vibe'];
244
- if (onlineCount > 0) parts.push(`${onlineCount} online`);
244
+ // "2 online" under a board showing three rows — one of them you — reads as a
245
+ // contradiction, even though both numbers are right. The footer counts the room
246
+ // AROUND you, so it should say so.
247
+ if (onlineCount > 0) parts.push(`${onlineCount} other${onlineCount === 1 ? '' : 's'}`);
245
248
  if (unreadCount > 0) parts.push(`**${unreadCount} unread**`);
246
249
  if (liveCount > 0) parts.push(`${liveCount} live`);
247
250
  let footer = `\n\n────────────────────────\n${parts.join(' · ')}`;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.5",
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
@@ -39,7 +39,7 @@ const API_BASE = 'https://www.slashvibe.dev';
39
39
  function printBanner() {
40
40
  console.log(`
41
41
  ${colors.green} █░█ █ █▄▄ █▀▀${colors.reset}
42
- ${colors.green} ▀▄▀ █ █▄█ ██▄${colors.reset} ${colors.dim}ship together${colors.reset}
42
+ ${colors.green} ▀▄▀ █ █▄█ ██▄${colors.reset} ${colors.dim}ask here · answer there${colors.reset}
43
43
  ──────────────────────────────────────────────────
44
44
  `);
45
45
  }
@@ -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/dm.js CHANGED
@@ -110,10 +110,27 @@ async function handler(args) {
110
110
  origin: origin || 'composed',
111
111
  });
112
112
 
113
- // Check for errors
113
+ // Check for errors.
114
+ //
115
+ // The generic tail — "please try again, check your connection" — used to be appended
116
+ // to EVERY failure, including the ones that already name their own fix. An expired
117
+ // session then read as: run `vibe init`, and also try again, and also check your
118
+ // connection. Three instructions, two of them wrong, at the moment someone's first
119
+ // message failed. docs/ROOM-TONE.md: an error names the fix in the same sentence as
120
+ // the problem — which means not adding a second, contradictory one after it.
121
+ //
122
+ // Errors carrying a specific remedy (auth_expired, not_signed_in, handle_not_found)
123
+ // are shown as-is. Only genuinely unknown failures get the retry hint.
114
124
  if (result && result.error) {
125
+ const REMEDY_CARRYING = new Set([
126
+ 'auth_expired', 'not_signed_in', 'auth_failed',
127
+ 'handle_not_found', 'self_dm', 'storage_error',
128
+ ]);
129
+ const detail = result.message || 'the message did not send.';
115
130
  return {
116
- display: `❌ **Failed to send message**\n\n${result.message}\n\n_Please try again. If the problem persists, check your connection._`
131
+ display: REMEDY_CARRYING.has(result.error)
132
+ ? detail
133
+ : `${detail}\n\n_worth one retry — if it keeps failing, say_ \`vibe doctor\`_._`,
117
134
  };
118
135
  }
119
136
 
package/tools/inbox.js CHANGED
@@ -183,7 +183,7 @@ async function handler(args) {
183
183
  // If there are guest messages but no DMs, show guest messages
184
184
  if (guestSection) {
185
185
  return {
186
- display: guestSection + '📭 No DM threads yet. Say "dm @someone" to start.',
186
+ display: guestSection + '📭 no messages yet — say "dm @someone" to start one.',
187
187
  actions: formatActions(actions.recommendedConnections([]))
188
188
  };
189
189
  }
@@ -197,6 +197,9 @@ async function handler(args) {
197
197
  'All caught up!',
198
198
  'No messages yet.'
199
199
  );
200
+ // NOTE: reachable only with VIBE_EXTRAS — the suggestion block is off by
201
+ // default (private fabric: no proposing strangers). The default empty inbox
202
+ // is the branch below.
200
203
  return {
201
204
  display,
202
205
  actions: recommendations.actions
@@ -206,20 +209,17 @@ async function handler(args) {
206
209
  console.log('[inbox] recommendations error:', e.message);
207
210
  }
208
211
 
209
- // Fallback: Check onboarding status to customize CTA
210
- let cta = 'Say "dm @someone" to start';
211
- try {
212
- const checklist = await store.getChecklistStatus(myHandle);
213
- if (checklist.success && checklist.tasks) {
214
- const repliedToSeth = checklist.tasks.find(t => t.id === 'reply_seth')?.done;
215
- if (!repliedToSeth) {
216
- cta = 'Reply to @vibe to get started!';
217
- }
218
- }
219
- } catch (e) {}
220
-
212
+ // THE FIRST SCREEN A NEW INVITEE SEES. Designed, not apologised for: an empty
213
+ // room should say what to do next in the same breath as being empty.
214
+ //
215
+ // The old copy pointed at "@vibe" — a system account — as the thing to reply to.
216
+ // Under docs/PRIVATE-FABRIC.md you are here because a PERSON invited you, and the
217
+ // useful first move is answering them, not a bot.
221
218
  return {
222
- display: `📭 No messages yet. ${cta}`,
219
+ display:
220
+ '📭 no messages yet.\n\n' +
221
+ '_whoever invited you is the place to start:_ `dm @them`\n' +
222
+ "_they'll get it in their next session — neither of you has to be online._",
223
223
  actions: formatActions(actions.recommendedConnections([]))
224
224
  };
225
225
  }
@@ -284,10 +284,15 @@ async function handler(args) {
284
284
  console.log('[inbox] recommendations error:', e.message);
285
285
  }
286
286
 
287
- // Fallback: show recent threads
288
- const recentHandles = sorted.slice(0, 3).map(t => `@${t.handle}`).join(', ');
287
+ // Caught up, WITH history — a different state from "you just arrived", and they
288
+ // were rendering as the same sentence. "Recent: @a, @b, @c" also named the
289
+ // handles without saying what they were: recent conversations, recent arrivals,
290
+ // or a suggestion? Naming the relationship costs three words.
291
+ const recentHandles = sorted.slice(0, 3).map((th) => `@${th.handle}`).join(', ');
289
292
  return {
290
- display: `📭 All caught up! Recent: ${recentHandles}`,
293
+ display: recentHandles
294
+ ? `📭 nothing unread.\n\n_your recent threads:_ ${recentHandles}\n_open one with_ \`vibe inbox @handle\``
295
+ : '📭 nothing unread.',
291
296
  actions: formatActions(actions.recommendedConnections([]))
292
297
  };
293
298
  }
package/tools/init.js CHANGED
@@ -59,7 +59,7 @@ function generatePreAuthBanner(presence) {
59
59
  const label = formatPresenceLabel(presence);
60
60
  const onlineText = label ? `🟢 ${label}` : '🟢 join the crew';
61
61
  return `
62
- █░█ █ █▄▄ █▀▀ 🚀 ship together
62
+ █░█ █ █▄▄ █▀▀ ask here · answer there
63
63
  ▀▄▀ █ █▄█ ██▄ ${onlineText}
64
64
  ──────────────────────────────────────────────────
65
65
  `;
@@ -75,7 +75,7 @@ function generateAuthBanner(handle, unreadCount, presence) {
75
75
  const unreadCol = unreadCount > 0 ? `📬 ${unreadCount} unread`.padEnd(14) : `📬 0 messages`.padEnd(14);
76
76
  const onlineText = formatPresenceLabel(presence) || 'just you so far';
77
77
 
78
- return ` █░█ █ █▄▄ █▀▀ ${handleCol} 🚀 ship together
78
+ return ` █░█ █ █▄▄ █▀▀ ${handleCol} ask here · answer there
79
79
  ▀▄▀ █ █▄█ ██▄ ${unreadCol} 🟢 ${onlineText}
80
80
  ──────────────────────────────────────────────────`;
81
81
  }
package/tools/start.js CHANGED
@@ -167,7 +167,7 @@ function generateWelcomeCard({ handle, onlineCount, unreadCount, versionInfo })
167
167
  versionSuffix = ' ⬆️';
168
168
  }
169
169
 
170
- return ` █░█ █ █▄▄ █▀▀ ${handleCol} 🚀 ship together
170
+ return ` █░█ █ █▄▄ █▀▀ ${handleCol} ask here · answer there
171
171
  ▀▄▀ █ █▄█ ██▄ ${unreadCol} 🟢 ${onlineCount} online${versionSuffix}
172
172
  ──────────────────────────────────────────────────`;
173
173
  }
@@ -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: [] },
@@ -312,16 +336,20 @@ say hi to **@vibe** (the platform account, always around), or send
312
336
  }
313
337
  }
314
338
 
315
- // Fun quick actions - randomize suggestions
316
- const quickActions = [
317
- `say "message @handle" to reach someone`,
318
- `"play with @handle" if you'd rather build something together`,
319
- `"status shipping" so others see what you're on`,
320
- ];
321
- const randomAction = quickActions[Math.floor(Math.random() * quickActions.length)];
322
-
339
+ // One line, and it must name a tool that is ACTUALLY REGISTERED.
340
+ //
341
+ // This rotated three suggestions at random, one of which advertised `play` — a
342
+ // culture-layer tool that 0.8.0 moved behind VIBE_EXTRAS. So the default board
343
+ // routinely told people to use something their session does not have. That is the
344
+ // help-drift class we already have a guard for on `vibe_help`; the board escaped it.
345
+ //
346
+ // Randomness went too: a board that says something different every time reads as
347
+ // decoration. The hint now follows from state — if you have not set a status,
348
+ // that is the useful thing to say.
323
349
  display += `---\n`;
324
- display += randomAction;
350
+ display += config.getOneLiner()
351
+ ? `say "dm @handle" to reach someone`
352
+ : `say "status <what you're on>" so others see what you're building`;
325
353
 
326
354
  // Check for unread to add urgency
327
355
  try {
@@ -336,17 +364,15 @@ say hi to **@vibe** (the platform account, always around), or send
336
364
 
337
365
  }
338
366
 
339
- // Genesis spots remaining
340
- try {
341
- const stats = await store.getStats();
342
- if (stats.genesis && stats.genesis.genesis_remaining > 0) {
343
- display += `\n\n_${stats.genesis.genesis_remaining} genesis spots left of ${stats.genesis.genesis_cap}_`;
344
- } else if (stats.genesis && stats.genesis.genesis_remaining === 0) {
345
- display += `\n\n_genesis is full — ${stats.genesis.total} builders_`;
346
- }
347
- } catch (e) {
348
- // Silent fail — genesis display is nice-to-have
349
- }
367
+ // The scarcity line is gone.
368
+ //
369
+ // "genesis is full — 282 builders" was a growth-era artifact on two counts. It sold
370
+ // scarcity, which docs/PRIVATE-FABRIC.md removed from scope; and the number was a
371
+ // handle count, not people — 282 "builders" on a network where a handful of humans
372
+ // are actually active reads as a claim the room immediately contradicts. Nobody
373
+ // invited into a private fabric needs to be told the list is closed.
374
+ //
375
+ // What the board says about who is here is now only what it can see: the rows above.
350
376
 
351
377
  // If not initialized, append join prompt instead of actions/unread
352
378
  if (!isInit) {
package/version.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
- "version": "0.8.5",
3
- "updated": "2026-07-31",
4
- "changelog": "Identity is one thing now. Whoever your credential says you are is who the client shows, routes and self-checks as \u2014 previously a saved handle could name one account while requests went out as another. Presence waits for the server to confirm you before it turns green, so a stale or forged session no longer announces itself. Changing your handle is turned off for now: your messages and tasks are still tied to it, and both rename paths are closed rather than one. A suggested first message no longer contains anyone else's words.",
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
- "The credential names you everywhere the client asks who you are",
7
- "Presence turns green only after the server confirms the session",
8
- "Handle changes are off \u2014 both routes, not one \u2014 until ownership moves off handles",
9
- "Suggested messages contain only our words; what someone shipped is shown as data"
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,