slashvibe-mcp 0.8.21 → 0.8.23

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/config.js CHANGED
@@ -82,22 +82,40 @@ function load() {
82
82
 
83
83
  function save(config) {
84
84
  ensureDir();
85
- // Load existing to preserve fields we're not updating
85
+ // Load existing to preserve fields we're not updating.
86
+ //
87
+ // Every field below falls back to `existing`, so a file that failed to parse
88
+ // does not merge into this write — it VANISHES from it, and the auth token
89
+ // with it. Signing someone out is not a repair for a file we could not read.
86
90
  let existing = {};
87
- try {
88
- if (fs.existsSync(PRIMARY_CONFIG)) {
91
+ if (fs.existsSync(PRIMARY_CONFIG)) {
92
+ try {
89
93
  existing = JSON.parse(fs.readFileSync(PRIMARY_CONFIG, 'utf8'));
94
+ } catch (e) {
95
+ console.error('Refusing to write config: the file on disk could not be read.', e.message);
96
+ return false;
90
97
  }
91
- } catch (e) {}
98
+ }
99
+
100
+ // "Did the caller mention this field?" — distinct from "is its value truthy".
101
+ // The fallbacks below are truthy-or-existing, which cannot express a removal:
102
+ // removeKeypair() deleted the keys and save() restored them from disk, so
103
+ // vibe_token's "old local keys removed" was never true. For the fields where
104
+ // an explicit empty value is a real instruction, presence decides.
105
+ const has = (k) => !!config && Object.prototype.hasOwnProperty.call(config, k);
92
106
 
93
107
  // Save to primary config (~/.vibe/config.json)
94
108
  const data = {
95
109
  username: config.handle || config.username || existing.username,
96
- workingOn: config.one_liner || config.workingOn || existing.workingOn,
110
+ // An empty one_liner is a person clearing what they're working on, not an
111
+ // absent update; the truthy chain kept showing the previous line forever.
112
+ workingOn: has('one_liner') ? config.one_liner
113
+ : has('workingOn') ? config.workingOn
114
+ : existing.workingOn,
97
115
  createdAt: config.createdAt || existing.createdAt || new Date().toISOString().split('T')[0],
98
116
  // AIRC keypair (persisted across sessions)
99
- publicKey: config.publicKey || existing.publicKey || null,
100
- privateKey: config.privateKey || existing.privateKey || null,
117
+ publicKey: has('publicKey') ? (config.publicKey ?? null) : (existing.publicKey || null),
118
+ privateKey: has('privateKey') ? (config.privateKey ?? null) : (existing.privateKey || null),
101
119
  // Guided mode (AskUserQuestion menus)
102
120
  guided_mode: config.guided_mode !== undefined ? config.guided_mode : existing.guided_mode,
103
121
  // GitHub Activity settings
@@ -107,8 +125,43 @@ function save(config) {
107
125
  authToken: config.authToken || config.privyToken || existing.authToken || existing.privyToken || null,
108
126
  authMethod: config.authMethod || existing.authMethod || null
109
127
  };
128
+ // Fields this function does not enumerate — x_credentials, firstDmSent,
129
+ // pendingAuth, visible — used to vanish on every save, because the object
130
+ // above is built field by field.
131
+ //
132
+ // Two layers are needed, not one. Spreading `existing` keeps what was already
133
+ // on disk (each key in `data` already falls back to its existing value, so the
134
+ // overlay never replaces a real value with a null it invented). But callers
135
+ // also SET these fields — `cfg.pendingAuth = true`, `cfg.visible = true`,
136
+ // `save({firstDmSent: true})` — and those writes were dropped just as
137
+ // silently. Keeping only `existing` would preserve the old value and still
138
+ // ignore the update, which reads as working and isn't. So the caller's own
139
+ // non-translated keys go on top of `existing` and under `data`.
140
+ //
141
+ // TRANSLATED names are excluded because they are aliases the block above
142
+ // already resolved; passing them through would write both spellings.
143
+ const TRANSLATED = new Set([
144
+ 'handle', 'one_liner', 'username', 'workingOn', 'createdAt',
145
+ 'publicKey', 'privateKey', 'guided_mode', 'authToken', 'privyToken',
146
+ 'authMethod', 'github_activity_enabled', 'github_activity_privacy',
147
+ ]);
148
+ const fromCaller = {};
149
+ for (const [k, v] of Object.entries(config || {})) {
150
+ if (!TRANSLATED.has(k)) fromCaller[k] = v;
151
+ }
152
+ const merged = { ...existing, ...fromCaller, ...data };
153
+
110
154
  // 0600: this file carries the auth token — it is a credential, not a preference.
111
- fs.writeFileSync(PRIMARY_CONFIG, JSON.stringify(data, null, 2), { mode: 0o600 });
155
+ const tmp = `${PRIMARY_CONFIG}.${process.pid}.${Date.now()}.tmp`;
156
+ try {
157
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
158
+ fs.renameSync(tmp, PRIMARY_CONFIG);
159
+ } catch (e) {
160
+ try { fs.unlinkSync(tmp); } catch {}
161
+ console.error('Failed to save config:', e.message);
162
+ return false;
163
+ }
164
+ return true;
112
165
  }
113
166
 
114
167
  function getHandle() {
@@ -198,6 +251,19 @@ function generateSessionId() {
198
251
  return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 10);
199
252
  }
200
253
 
254
+ // Distinguishes "no session yet" (absent) from "unreadable" (present, corrupt),
255
+ // which getSessionData() cannot: both come back as null.
256
+ function sessionFileIsReadable() {
257
+ if (!fs.existsSync(SESSION_FILE)) return true;
258
+ try {
259
+ const content = fs.readFileSync(SESSION_FILE, 'utf8').trim();
260
+ if (content.startsWith('{')) JSON.parse(content);
261
+ return true;
262
+ } catch (e) {
263
+ return false;
264
+ }
265
+ }
266
+
201
267
  function getSessionData() {
202
268
  try {
203
269
  if (fs.existsSync(SESSION_FILE)) {
@@ -215,7 +281,20 @@ function getSessionData() {
215
281
 
216
282
  function saveSessionData(data) {
217
283
  ensureDir();
218
- fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2));
284
+ if (!sessionFileIsReadable()) {
285
+ console.error('Refusing to write session data: the file on disk could not be read.');
286
+ return false;
287
+ }
288
+ const tmp = `${SESSION_FILE}.${process.pid}.${Date.now()}.tmp`;
289
+ try {
290
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
291
+ fs.renameSync(tmp, SESSION_FILE);
292
+ } catch (e) {
293
+ try { fs.unlinkSync(tmp); } catch {}
294
+ console.error('Failed to save session data:', e.message);
295
+ return false;
296
+ }
297
+ return true;
219
298
  }
220
299
 
221
300
  function getSessionId() {
@@ -375,9 +454,11 @@ const hasPrivyAuth = hasOAuth;
375
454
  */
376
455
  function removeKeypair() {
377
456
  const cfg = load();
378
- delete cfg.publicKey;
379
- delete cfg.privateKey;
380
- save(cfg);
457
+ // Explicitly null, not deleted: an absent key means "no instruction" to
458
+ // save(), and the old value came straight back off disk.
459
+ cfg.publicKey = null;
460
+ cfg.privateKey = null;
461
+ const saved = save(cfg);
381
462
 
382
463
  // Also clear from session
383
464
  const data = getSessionData();
@@ -386,6 +467,7 @@ function removeKeypair() {
386
467
  delete data.privateKey;
387
468
  saveSessionData(data);
388
469
  }
470
+ return saved;
389
471
  }
390
472
 
391
473
  /**
package/index.js CHANGED
@@ -56,7 +56,12 @@ const SKIP_FOOTER_TOOLS = ['vibe_init', 'vibe_doctor', 'vibe_test', 'vibe_update
56
56
  // The people actions each end in ONE obvious next action; the ambient
57
57
  // footer would stack a second one AND name a specific recipient to reply
58
58
  // to — the choosing is the human's, so the footer stays off here.
59
- 'vibe_people', 'vibe_list_me', 'vibe_unlist_me'];
59
+ 'vibe_people', 'vibe_list_me', 'vibe_unlist_me',
60
+ // The first screen states the unread count itself and shows ids rather than
61
+ // bodies. The footer would state that count a SECOND time from a different
62
+ // (cached) source — the two disagreed in a real session — and re-render the
63
+ // message bodies the screen deliberately withholds.
64
+ 'vibe_start'];
60
65
 
61
66
  // Progressive disclosure: only these tools are visible before authentication
62
67
  // After auth, the full toolset is revealed via tools/list_changed notification
@@ -590,7 +595,11 @@ class VibeMCPServer {
590
595
  // vibe_init reads only {handle, one_liner, auth_method} and ignores any
591
596
  // extra keys, so this is safe for every command yet lets relevant
592
597
  // context (e.g. a handle the caller already supplied) carry into auth.
593
- 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({});
594
603
  const initDisplay = initResult.display || JSON.stringify(initResult, null, 2);
595
604
 
596
605
  // After auth, emit tools/list_changed so Claude sees full toolset
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
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
@@ -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/store/api.js CHANGED
@@ -354,11 +354,21 @@ async function getTypingUsers(forHandle) {
354
354
  }
355
355
  }
356
356
 
357
- async function getActiveUsers() {
357
+ async function getActiveUsersInner() {
358
358
  try {
359
359
  const endpoint = USE_V2_PRESENCE ? '/api/v2/presence' : '/api/presence';
360
360
  const result = await request('GET', endpoint);
361
361
 
362
+ // request() RESOLVES on transport failure ({success:false, network:true}),
363
+ // so without this the lists below are simply absent and a dead network maps
364
+ // to a successful empty room. 401 is deliberately excluded: that is the
365
+ // "signed out, here are public counts" path handled below, not a failure.
366
+ if (result?.success === false && result.statusCode !== 401) {
367
+ const err = new Error(result.error || 'presence request failed');
368
+ err.code = result.network ? 'transport_failed' : `http_${result.statusCode || 'error'}`;
369
+ throw err;
370
+ }
371
+
362
372
  // Combine active + away, plus any AGENTS currently live in a room (e.g.
363
373
  // @coltrane hosting the cantina). Agents live in their own array; without
364
374
  // this a live agent host never reached the footer's live-room line.
@@ -446,10 +456,38 @@ async function getActiveUsers() {
446
456
  return mappedUsers;
447
457
  } catch (e) {
448
458
  console.error('Who failed:', e.message);
449
- return [];
459
+ const err = new Error(e?.message || 'presence read failed');
460
+ err.code = e?.code || 'transport_failed';
461
+ throw err;
462
+ }
463
+ }
464
+
465
+ // The same outcome-preserving shape the inbox uses: flattening a failed
466
+ // presence read to [] made "0 others here" a claim nobody verified.
467
+ async function getActiveUsersResult() {
468
+ try {
469
+ const users = await getActiveUsersInner();
470
+ if (!Array.isArray(users)) return { ok: false, users: [], error: 'malformed_response' };
471
+ // Signed out is not an empty room either — the server told us it would not
472
+ // say who is here. A caller rendering a count must not treat that as zero.
473
+ //
474
+ // The ARRAY ITSELF is returned, not a fresh []: it carries `anonymous` and
475
+ // `counts` as non-enumerable properties, and who.js reads them to say "4
476
+ // people are here, sign in to see who". Substituting a bare [] there turned
477
+ // that back into "Quiet right now — you're the only one here", the exact
478
+ // sentence those properties exist to prevent.
479
+ if (users.anonymous) return { ok: false, users, error: 'unauthenticated' };
480
+ return { ok: true, users };
481
+ } catch (e) {
482
+ return { ok: false, users: [], error: e?.code || 'transport_failed', message: e?.message };
450
483
  }
451
484
  }
452
485
 
486
+ // Named callers keep the old shape: an empty list on failure, as before.
487
+ async function getActiveUsers() {
488
+ return (await getActiveUsersResult()).users;
489
+ }
490
+
453
491
  async function setVisibility(handle, visible) {
454
492
  try {
455
493
  const endpoint = USE_V2_PRESENCE ? '/api/v2/presence' : '/api/presence';
@@ -600,7 +638,30 @@ async function sendMessage(from, to, body, type = 'dm', payload = null, options
600
638
  }
601
639
  }
602
640
 
641
+ /**
642
+ * The inbox, and whether it was actually read.
643
+ *
644
+ * getInbox() swallows transport failures into [] for its many callers, which
645
+ * makes "no threads" and "could not ask" the same value — and a caller that
646
+ * renders a claim from that (vibe_start did) states a fact nobody has
647
+ * (review P1). This is the same call with the outcome kept:
648
+ * { ok: true, threads } the server answered
649
+ * { ok: false, threads: [], error } nobody answered, or the API refused
650
+ */
651
+ async function getInboxResult(handle) {
652
+ try {
653
+ const threads = await getInboxInner(handle);
654
+ return Array.isArray(threads) ? { ok: true, threads } : { ok: false, threads: [], error: 'malformed_response' };
655
+ } catch (e) {
656
+ return { ok: false, threads: [], error: e?.code || 'transport_failed', message: e?.message };
657
+ }
658
+ }
659
+
603
660
  async function getInbox(handle) {
661
+ return (await getInboxResult(handle)).threads;
662
+ }
663
+
664
+ async function getInboxInner(handle) {
604
665
  try {
605
666
  // V2: Use threads endpoint (Postgres-backed, cross-client sync)
606
667
  if (USE_V2_MESSAGES) {
@@ -629,8 +690,10 @@ async function getInbox(handle) {
629
690
  // V1 fallback
630
691
  return getInboxV1(handle);
631
692
  } catch (e) {
693
+ // Rethrow: getInboxResult owns the outcome now, and getInbox() still
694
+ // presents [] to every caller that only wants the list.
632
695
  console.error('Inbox failed:', e.message);
633
- return [];
696
+ throw e;
634
697
  }
635
698
  }
636
699
 
@@ -640,10 +703,15 @@ async function getInboxV1(handle) {
640
703
  // /api/messages now returns V2 format: { threads, total_unread }
641
704
  const result = await request('GET', `/api/messages?user=${handle}`);
642
705
 
643
- // Check for API errors (auth failures, etc.)
706
+ // An API-level refusal (auth failure, server error) is a FAILED read, not
707
+ // an empty inbox. Returning [] here made "the server said no" and "you
708
+ // have no threads" the same value — the same swallow the transport path
709
+ // had, one layer down (review P1).
644
710
  if (result.success === false) {
645
711
  console.error('[getInbox] API error:', result.error, result.message);
646
- return [];
712
+ const err = new Error(result.message || result.error || 'inbox_refused');
713
+ err.code = result.error || 'inbox_refused';
714
+ throw err;
647
715
  }
648
716
 
649
717
  // V2 format: map threads to expected format
@@ -678,7 +746,7 @@ async function getInboxV1(handle) {
678
746
  }));
679
747
  } catch (e) {
680
748
  console.error('Inbox v1 failed:', e.message);
681
- return [];
749
+ throw e;
682
750
  }
683
751
  }
684
752
 
@@ -1387,6 +1455,7 @@ module.exports = {
1387
1455
  // Presence
1388
1456
  heartbeat,
1389
1457
  getActiveUsers,
1458
+ getActiveUsersResult,
1390
1459
  setVisibility,
1391
1460
  sendTypingIndicator,
1392
1461
  getTypingUsers,
@@ -1394,6 +1463,7 @@ module.exports = {
1394
1463
  // Messages
1395
1464
  sendMessage,
1396
1465
  getInbox,
1466
+ getInboxResult,
1397
1467
  getRawInbox,
1398
1468
  getUnreadCount,
1399
1469
  getThread,
package/store/local.js CHANGED
@@ -38,8 +38,33 @@ function loadPresence() {
38
38
  return {};
39
39
  }
40
40
 
41
+ function presenceIsReadable() {
42
+ if (!fs.existsSync(PRESENCE_FILE)) return true; // absent = a real first write
43
+ try {
44
+ JSON.parse(fs.readFileSync(PRESENCE_FILE, 'utf8'));
45
+ return true;
46
+ } catch (e) {
47
+ return false;
48
+ }
49
+ }
50
+
41
51
  function savePresence(presence) {
42
- fs.writeFileSync(PRESENCE_FILE, JSON.stringify(presence, null, 2));
52
+ // loadPresence() turns an unreadable file into {}, so an unguarded save would
53
+ // drop every other person's presence record.
54
+ if (!presenceIsReadable()) {
55
+ console.error('Refusing to write presence: the file on disk could not be read.');
56
+ return false;
57
+ }
58
+ const tmp = `${PRESENCE_FILE}.${process.pid}.${Date.now()}.tmp`;
59
+ try {
60
+ fs.writeFileSync(tmp, JSON.stringify(presence, null, 2));
61
+ fs.renameSync(tmp, PRESENCE_FILE);
62
+ } catch (e) {
63
+ try { fs.unlinkSync(tmp); } catch {}
64
+ console.error('Failed to save presence:', e.message);
65
+ return false;
66
+ }
67
+ return true;
43
68
  }
44
69
 
45
70
  async function heartbeat(handle, one_liner) {
@@ -53,6 +78,11 @@ async function heartbeat(handle, one_liner) {
53
78
  savePresence(presence);
54
79
  }
55
80
 
81
+ async function getActiveUsersResult() {
82
+ if (!presenceIsReadable()) return { ok: false, users: [], error: 'local_corrupt' };
83
+ return { ok: true, users: await getActiveUsers() };
84
+ }
85
+
56
86
  async function getActiveUsers() {
57
87
  const presence = loadPresence();
58
88
  const now = Date.now();
@@ -87,16 +117,38 @@ async function setVisibility(handle, visible) {
87
117
 
88
118
  // ============ MESSAGES ============
89
119
 
120
+ /**
121
+ * The messages file, with failures preserved.
122
+ *
123
+ * A file that does not exist yet IS an empty inbox — that is a real answer.
124
+ * A file that cannot be read, or whose lines do not parse, is NOT: it is a
125
+ * failed read, and flattening it to [] makes "nothing has happened" and
126
+ * "something is wrong" the same value (review P1 — the same swallow the API
127
+ * store had, two layers down).
128
+ */
129
+ function loadMessagesStrict() {
130
+ if (!fs.existsSync(MESSAGES_FILE)) return [];
131
+ const content = fs.readFileSync(MESSAGES_FILE, 'utf8');
132
+ return content.trim().split('\n')
133
+ .filter(line => line.length > 0)
134
+ .map((line, i) => {
135
+ try {
136
+ return JSON.parse(line);
137
+ } catch (e) {
138
+ const err = new Error(`messages.jsonl line ${i + 1} is not valid JSON`);
139
+ err.code = 'local_corrupt';
140
+ throw err;
141
+ }
142
+ });
143
+ }
144
+
145
+ // Unchanged contract for every caller that only wants the list.
90
146
  function loadMessages() {
91
147
  try {
92
- if (fs.existsSync(MESSAGES_FILE)) {
93
- const content = fs.readFileSync(MESSAGES_FILE, 'utf8');
94
- return content.trim().split('\n')
95
- .filter(line => line.length > 0)
96
- .map(line => JSON.parse(line));
97
- }
98
- } catch (e) {}
99
- return [];
148
+ return loadMessagesStrict();
149
+ } catch (e) {
150
+ return [];
151
+ }
100
152
  }
101
153
 
102
154
  function appendMessage(msg) {
@@ -127,6 +179,31 @@ async function getInbox(handle) {
127
179
  .sort((a, b) => b.timestamp - a.timestamp);
128
180
  }
129
181
 
182
+ /**
183
+ * The inbox, and whether it was actually read — the same contract the API
184
+ * store provides (store/api.js). Both implementations must answer it, or a
185
+ * caller that distinguishes "empty" from "could not ask" silently gets the
186
+ * wrong answer in the other mode (review P1: with VIBE_LOCAL=true, a missing
187
+ * method made every start claim the read had failed).
188
+ *
189
+ * A local file read either produces the list or throws; there is no partial
190
+ * or refused outcome to represent.
191
+ */
192
+ async function getInboxResult(handle) {
193
+ try {
194
+ // The STRICT loader: getInbox() flattens a corrupt or unreadable file to
195
+ // [], which is exactly the fact this wrapper exists to preserve.
196
+ const messages = loadMessagesStrict();
197
+ const h = handle.toLowerCase().replace('@', '');
198
+ const threads = messages
199
+ .filter((m) => m.to === h)
200
+ .sort((a, b) => b.timestamp - a.timestamp);
201
+ return { ok: true, threads };
202
+ } catch (e) {
203
+ return { ok: false, threads: [], error: e?.code || 'local_read_failed', message: e?.message };
204
+ }
205
+ }
206
+
130
207
  async function getUnreadCount(handle) {
131
208
  const inbox = await getInbox(handle);
132
209
  return inbox.filter(m => !m.read_at).length;
@@ -152,7 +229,19 @@ async function getThread(myHandle, theirHandle) {
152
229
  }
153
230
 
154
231
  async function markThreadRead(myHandle, theirHandle) {
155
- const messages = loadMessages();
232
+ // A READ-MODIFY-WRITE over the whole file must never run on a swallowed
233
+ // read (review P1 — DATA LOSS): loadMessages() returns [] for a corrupt or
234
+ // unreadable file, and the rewrite below would then replace every message,
235
+ // including the valid ones, with an empty file. A read that did not succeed
236
+ // is not permission to write; the mark is abandoned and the file is left
237
+ // exactly as it is.
238
+ let messages;
239
+ try {
240
+ messages = loadMessagesStrict();
241
+ } catch (e) {
242
+ console.error('[local] not marking read — the messages file could not be read:', e.message);
243
+ return { success: false, error: e.code || 'local_read_failed' };
244
+ }
156
245
  const me = myHandle.toLowerCase().replace('@', '');
157
246
  const them = theirHandle.toLowerCase().replace('@', '');
158
247
  const now = Date.now();
@@ -165,8 +254,21 @@ async function markThreadRead(myHandle, theirHandle) {
165
254
  return m;
166
255
  });
167
256
 
168
- // Rewrite the file
169
- fs.writeFileSync(MESSAGES_FILE, updated.map(m => JSON.stringify(m)).join('\n') + '\n');
257
+ // Rewrite the file. Reached only from a read that actually succeeded, and
258
+ // written via a temp file + rename so an interrupted write cannot leave a
259
+ // half-file behind either.
260
+ // A per-write temp name: a fixed one collides between concurrent marks, and
261
+ // a failed rename would leave it behind (review P2).
262
+ const tmp = `${MESSAGES_FILE}.${process.pid}.${Date.now()}.tmp`;
263
+ try {
264
+ fs.writeFileSync(tmp, updated.map(m => JSON.stringify(m)).join('\n') + '\n');
265
+ fs.renameSync(tmp, MESSAGES_FILE);
266
+ } catch (e) {
267
+ try { fs.unlinkSync(tmp); } catch {}
268
+ console.error('[local] mark-read write failed; the file is unchanged:', e.message);
269
+ return { success: false, error: e.code || 'local_write_failed' };
270
+ }
271
+ return { success: true };
170
272
  }
171
273
 
172
274
  // ============ SKILL EXCHANGES ============
@@ -239,10 +341,12 @@ module.exports = {
239
341
  // Presence
240
342
  heartbeat,
241
343
  getActiveUsers,
344
+ getActiveUsersResult,
242
345
  setVisibility,
243
346
 
244
347
  // Messages
245
348
  sendMessage,
349
+ getInboxResult,
246
350
  getInbox,
247
351
  getRawInbox,
248
352
  getUnreadCount,
package/store/profiles.js CHANGED
@@ -28,10 +28,38 @@ function loadProfiles() {
28
28
  return {};
29
29
  }
30
30
 
31
+ // True only when the store on disk is absent (a legitimate first write) or
32
+ // parses. A present-but-unreadable file loaded as {}, so writing over it would
33
+ // delete every profile it holds.
34
+ function storeIsReadable() {
35
+ if (!fs.existsSync(PROFILES_FILE)) return true;
36
+ try {
37
+ JSON.parse(fs.readFileSync(PROFILES_FILE, 'utf8'));
38
+ return true;
39
+ } catch (e) {
40
+ return false;
41
+ }
42
+ }
43
+
31
44
  // Save all profiles to disk
32
45
  function saveProfiles(profiles) {
46
+ // Every mutator above is a read-modify-write over the whole store, and
47
+ // loadProfiles() cannot tell "no profiles" from "unreadable". A read that did
48
+ // not succeed is not permission to write.
49
+ if (!storeIsReadable()) {
50
+ console.error('Refusing to write profiles: the store on disk could not be read.');
51
+ return false;
52
+ }
33
53
  try {
34
- fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles, null, 2));
54
+ const tmp = `${PROFILES_FILE}.${process.pid}.${Date.now()}.tmp`;
55
+ try {
56
+ fs.writeFileSync(tmp, JSON.stringify(profiles, null, 2));
57
+ fs.renameSync(tmp, PROFILES_FILE);
58
+ } catch (e) {
59
+ try { fs.unlinkSync(tmp); } catch {}
60
+ throw e;
61
+ }
62
+ return true;
35
63
  } catch (e) {
36
64
  console.error('Failed to save profiles:', e.message);
37
65
  }
package/tools/inbox.js CHANGED
@@ -222,12 +222,21 @@ async function handler(args) {
222
222
  // all list/ambient concerns and must not hold this path open.
223
223
  if (requestedHandle) {
224
224
  const thread = await store.getThread(myHandle, requestedHandle);
225
- await store.markThreadRead(
225
+ // markThreadRead can now REFUSE (a read that failed is not permission to
226
+ // write). A refusal means the thread on disk could not be read, so an
227
+ // empty render would be a claim about a conversation nobody looked at.
228
+ const marked = await store.markThreadRead(
226
229
  myHandle,
227
230
  requestedHandle,
228
231
  thread._lastMessageId,
229
232
  thread._threadId
230
233
  );
234
+ if (marked && marked.success === false) {
235
+ return {
236
+ display: `Couldn't read your thread with @${requestedHandle} — nothing is shown rather than an empty conversation.`,
237
+ footer: 'minimal',
238
+ };
239
+ }
231
240
 
232
241
  if (thread.some(m => m.from === requestedHandle)) {
233
242
  patterns.logMessageReceived(requestedHandle);
@@ -399,7 +408,10 @@ async function handler(args) {
399
408
 
400
409
  // Fetch full thread and mark as read
401
410
  const thread = await store.getThread(myHandle, them);
402
- await store.markThreadRead(myHandle, them, thread._lastMessageId, thread._threadId);
411
+ const marked = await store.markThreadRead(myHandle, them, thread._lastMessageId, thread._threadId);
412
+ if (marked && marked.success === false) {
413
+ return { display: `Couldn't read your thread with @${them} — nothing is shown rather than an empty conversation.` };
414
+ }
403
415
 
404
416
  // Auto-track readWelcomeAt if viewing welcome from @brightseth
405
417
  const isWelcomeThread = them.toLowerCase() === 'brightseth';
package/tools/init.js CHANGED
@@ -334,7 +334,10 @@ async function completeSignIn({ token, handle: finalHandle, actor }, one_liner)
334
334
  authStore.setOneLiner(one_liner || '');
335
335
  const authConfig = config.load();
336
336
  authConfig.handle = finalHandle;
337
- authConfig.one_liner = one_liner || '';
337
+ // Only when we were actually given one. save() can now express a clear, and
338
+ // `one_liner || ''` would make signing in erase the line you set last week —
339
+ // an instruction nobody gave.
340
+ if (one_liner) authConfig.one_liner = one_liner;
338
341
  authConfig.authMethod = 'browser';
339
342
  authConfig.pendingAuth = false;
340
343
  config.save(authConfig);
@@ -535,7 +538,7 @@ Heading out? \`vibe bye\` ends presence for this session — you stay @${existin
535
538
  // Save one_liner for the completion handler
536
539
  const cfg = config.load();
537
540
  if (h) cfg.handle = h;
538
- cfg.one_liner = one_liner || '';
541
+ if (one_liner) cfg.one_liner = one_liner;
539
542
  cfg.pendingAuth = true;
540
543
  config.save(cfg);
541
544
 
@@ -578,7 +581,7 @@ Heading out? \`vibe bye\` ends presence for this session — you stay @${existin
578
581
 
579
582
  const cfg = config.load();
580
583
  cfg.handle = h;
581
- cfg.one_liner = one_liner || '';
584
+ if (one_liner) cfg.one_liner = one_liner; // same: absent is not "clear it"
582
585
  cfg.visible = true;
583
586
  cfg.authMethod = 'legacy';
584
587
  config.save(cfg);
package/tools/reply.js CHANGED
@@ -173,7 +173,11 @@ async function handler(args) {
173
173
 
174
174
  // Mark the thread as read since we're replying
175
175
  try {
176
- await store.markThreadRead(myHandle, targetHandle);
176
+ // A refusal is not an exception, so `catch` alone could not see it.
177
+ const marked = await store.markThreadRead(myHandle, targetHandle);
178
+ if (marked && marked.success === false) {
179
+ console.warn('[reply] thread not marked read:', marked.error);
180
+ }
177
181
  } catch (e) {
178
182
  // Non-fatal - continue
179
183
  console.warn('[reply] Failed to mark thread as read:', e.message);
package/tools/start.js CHANGED
@@ -17,9 +17,7 @@ const { inertField } = require('../incoming');
17
17
  const store = require('../store');
18
18
  const memory = require('../memory');
19
19
  const patterns = require('../intelligence/patterns');
20
- const { actions, formatActions } = require('./_actions');
21
- const { firstDmNudge, isHereNow } = require('./_shared');
22
- const { weaveMoment } = require('./weave');
20
+ const { isHereNow, normalizeHandle } = require('./_shared');
23
21
  const init = require('./init');
24
22
  const { gatherWithTimeout } = require('./_work-context');
25
23
 
@@ -151,25 +149,6 @@ function compareVersions(v1, v2) {
151
149
  return 0;
152
150
  }
153
151
 
154
- /**
155
- * Generate ASCII welcome card - matches init.js format
156
- * Format: logo | handle + unread | tagline + online
157
- */
158
- function generateWelcomeCard({ handle, onlineCount, unreadCount, versionInfo }) {
159
- // Match init.js generateAuthBanner format for consistency
160
- const handleCol = `@${handle}`.padEnd(16);
161
- const unreadCol = unreadCount > 0 ? `📬 ${unreadCount} unread`.padEnd(14) : `📬 0 messages`.padEnd(14);
162
-
163
- // Add version badge if available
164
- let versionSuffix = '';
165
- if (versionInfo?.hasUpdate) {
166
- versionSuffix = ' ⬆️';
167
- }
168
-
169
- return ` █░█ █ █▄▄ █▀▀ ${handleCol} ask here · answer there
170
- ▀▄▀ █ █▄█ ██▄ ${unreadCol} 🟢 ${onlineCount} online${versionSuffix}
171
- ──────────────────────────────────────────────────`;
172
- }
173
152
 
174
153
  const definition = {
175
154
  name: 'vibe_start',
@@ -205,7 +184,6 @@ async function handler(args) {
205
184
 
206
185
  // Step 2: User is authenticated - show dashboard
207
186
  const myHandle = config.getHandle();
208
- let threads = [];
209
187
  let updateNotice = '';
210
188
 
211
189
  // If we just updated, show a notice
@@ -213,9 +191,6 @@ async function handler(args) {
213
191
  updateNotice = `\n\n⬆️ **Updated v${updateResult.from} → v${updateResult.to}** — restart your coding agent to apply`;
214
192
  }
215
193
 
216
- // Fetch version info early (non-blocking, cached)
217
- const versionInfo = await getVersionInfo().catch(() => null);
218
-
219
194
  // ═══════════════════════════════════════════════════════════════════════
220
195
  // AMBIENT CONTEXT: Gather work context and auto-set presence
221
196
  // ═══════════════════════════════════════════════════════════════════════
@@ -245,48 +220,42 @@ async function handler(args) {
245
220
  // Log session start for patterns
246
221
  patterns.logSessionStart(myHandle);
247
222
 
248
- // Get threads for memory context
249
- try {
250
- threads = memory.listThreads();
251
- } catch (e) {}
252
-
253
223
  // Step 2: Get who's around
254
- const users = await store.getActiveUsers();
255
- const others = users.filter(u => u.handle !== myHandle);
224
+ // A presence request that FAILED is not an empty room (round-7 review):
225
+ // getActiveUsers flattens transport failure to [], so without this outcome
226
+ // "0 others here" is a claim nobody verified.
227
+ const presence = await store.getActiveUsersResult();
228
+ const presenceRead = presence.ok;
229
+ const users = presence.users;
230
+ // Compared normalized: an exact !== lets a differently-cased or @-prefixed
231
+ // copy of your own handle through, and then "N others here" counts you.
232
+ const me = normalizeHandle(myHandle || '');
233
+ const others = users.filter(u => normalizeHandle(u.handle || '') !== me);
256
234
  // GREEN MEANS A RECENT CONFIRMED HEARTBEAT — the same isHereNow gate who and
257
235
  // dm use. getActiveUsers returns active+away merged; rendering that union
258
236
  // under 🟢 told users someone was live who last breathed 25 minutes ago.
259
237
  const hereNow = others.filter(isHereNow);
260
- const away = others.filter(u => !isHereNow(u));
261
238
 
262
- // Step 3: Check inbox
239
+ // Step 3: Check inbox. A FAILED read is not an empty inbox (review P1):
240
+ // getInbox swallows transport errors into [], so without this flag a
241
+ // network failure renders as "0 unread" and as "no messages yet" — two
242
+ // claims nothing supports.
263
243
  let unreadCount = 0;
264
244
  let inboxThreads = [];
245
+ let inboxRead = false;
265
246
  try {
266
- // Fetch full inbox (not just count) so we can include summaries
267
- inboxThreads = await store.getInbox(myHandle);
247
+ // getInboxResult keeps the outcome that getInbox() flattens away.
248
+ const read = await store.getInboxResult(myHandle);
249
+ inboxRead = read.ok === true;
250
+ inboxThreads = read.threads || [];
268
251
  unreadCount = inboxThreads.reduce((sum, t) => sum + (t.unread || 0), 0);
269
- } catch (e) {}
252
+ } catch (e) {
253
+ inboxThreads = [];
254
+ }
270
255
 
271
- // Step 3b: Check for guest session messages + pair status (multiplayer)
272
- let guestMessages = [];
273
- let pairStatus = null;
274
- try {
275
- const apiUrl = config.getApiUrl();
276
- const headers = apiHeaders();
277
- const [guestResp, pairResp] = await Promise.all([
278
- fetch(`${apiUrl}/api/session/guest?handle=${encodeURIComponent(myHandle)}`, { headers }),
279
- fetch(`${apiUrl}/api/pair?handle=${encodeURIComponent(myHandle)}`, { headers }),
280
- ]);
281
- const guestData = await guestResp.json();
282
- if (guestData.success && guestData.messages && guestData.messages.length > 0) {
283
- guestMessages = guestData.messages;
284
- }
285
- const pairData = await pairResp.json();
286
- if (pairData.success && pairData.paired) {
287
- pairStatus = pairData;
288
- }
289
- } catch (e) {}
256
+ // Step 3b removed with the first-screen rewrite: the guest/pair fetches
257
+ // cost two HTTP round trips on every start and fed blocks this screen no
258
+ // longer renders. vibe_guest still owns that surface.
290
259
 
291
260
  // Step 4 used to fetch /api/suggestions and render "Suggested connections" — three
292
261
  // strangers proposed on every session start, in the DEFAULT surface rather than behind
@@ -298,211 +267,77 @@ async function handler(args) {
298
267
  // nobody to say anything about. NOTE: `workContext.suggestions` elsewhere in this file
299
268
  // is unrelated — that is local work context, not people.
300
269
 
301
- // Generate the ASCII welcome card (matches init.js format)
302
- const welcomeCard = generateWelcomeCard({
303
- handle: myHandle,
304
- onlineCount: hereNow.length,
305
- unreadCount,
306
- versionInfo
307
- });
308
-
309
- // Build display with card + any additional info
310
- let display = welcomeCard;
311
-
312
- // Add who's online section (top 5 with what they're building).
313
- // Only isHereNow rows may sit under 🟢; everyone else is ○ away, in words.
314
- if (hereNow.length > 0) {
315
- const top5 = hereNow.slice(0, 5);
316
- display += `\n\n**🟢 Online now:**`;
317
- top5.forEach(u => {
318
- // status/one_liner/note are written by other users inert before they
319
- // land in this session's context (codex F8 coverage).
320
- const status = u.status ? ` (${inertField(u.status, 30)})` : '';
321
- const truncated = inertField(u.one_liner || u.note || '', 40);
322
- display += `\n• @${u.handle}${status}${truncated ? ' ' + truncated : ''}`;
323
- });
324
- if (hereNow.length > 5) {
325
- display += `\n• _+${hereNow.length - 5} more..._`;
326
- }
327
- }
328
- if (away.length > 0) {
329
- display += `\n${hereNow.length > 0 ? '' : '\n'}○ ${away.length} away`;
330
- }
331
-
332
- // First-DM activation nudge (dormant users only): if this user has never sent
333
- // a DM, point them at a real human who's around right now with a ready opener.
334
- // Gated inside firstDmNudge so we never nag people who already message.
335
- display += firstDmNudge(hereNow, threads);
336
-
337
- // Add unread messages section (if any)
338
- if (unreadCount > 0) {
339
- const unreadSenders = inboxThreads.filter(t => t.unread > 0);
340
- display += `\n\n**📬 Unread (${unreadCount}):**`;
341
- unreadSenders.slice(0, 3).forEach(t => {
342
- const truncated = inertField(t.lastMessage || '', 50);
343
- display += `\n• @${t.handle} (${t.unread}) — "${truncated}"`;
270
+ // ── THE FIRST SCREEN ────────────────────────────────────────────────
271
+ // One authoritative count, no message bodies, no chosen person, and the
272
+ // three things a person can actually do. Everything that used to live here
273
+ // — the presence list, message previews, the ambient footer's copy of the
274
+ // same messages, rotating tips, weave/guest/pair/memory blocks — either
275
+ // duplicated a fact stated elsewhere or made a claim this screen cannot
276
+ // verify. What a person needs on arrival is: who am I, what is waiting,
277
+ // and what can I say next.
278
+ const hereCount = hereNow.length;
279
+ const unreadSenders = inboxThreads.filter((t) => t.unread > 0);
280
+ // Server-supplied strings on a single line: a long handle or id would wrap
281
+ // and blow the line budget, and a control character would add literal lines
282
+ // (review P2).
283
+ const cell = (v, max) => inertField(String(v || ''), max);
284
+
285
+ let display = `/vibe @${cell(myHandle, 39)}`;
286
+ // ONE authoritative live-presence count: store.getActiveUsers(), filtered by
287
+ // the same isHereNow gate who and dm use, EXCLUDING the signed-in person.
288
+ // "N here" could be read as the room including you; "N others here" states
289
+ // exactly what was counted.
290
+ const counts = [
291
+ inboxRead ? `${unreadCount} unread` : "couldn't read your inbox",
292
+ presenceRead
293
+ ? `${hereCount} other${hereCount === 1 ? '' : 's'} here`
294
+ : "couldn't see who's here",
295
+ ];
296
+ display += `\n${counts.join(' · ')}`;
297
+
298
+ if (unreadSenders.length > 0) {
299
+ // Handle, count, and the STABLE id of the newest message — enough to
300
+ // reply to exactly that message. The words themselves stay in the thread
301
+ // until the person opens it.
302
+ display += '\n';
303
+ unreadSenders.slice(0, 5).forEach((t) => {
304
+ const id = t.lastMessageId ? ` · #${cell(t.lastMessageId, 40)}` : '';
305
+ display += `\n@${cell(t.handle, 39)} (${t.unread})${id}`;
344
306
  });
345
- if (unreadSenders.length > 3) {
346
- display += `\n• _+${unreadSenders.length - 3} more threads..._`;
307
+ if (unreadSenders.length > 5) {
308
+ display += `\n_+${unreadSenders.length - 5} more_`;
347
309
  }
310
+ } else if (inboxRead && inboxThreads.length === 0) {
311
+ // Only a PROVEN-empty inbox may be called a fresh arrival. Land them on
312
+ // whoever brought them here; no randomly chosen stranger, ever.
313
+ display += '\n\n_no messages yet — whoever invited you is the place to start_';
348
314
  }
349
315
 
350
- // The Weave — "Fable holds your half": if someone replied to a thread and you
351
- // haven't answered, surface the moment so the in-session model can draft your
352
- // reply in your voice and send it with one word. Best-effort, never blocks start.
353
- try {
354
- display += await weaveMoment(myHandle);
355
- } catch (e) {
356
- // weave is additive magic — a failure here must never break vibe_start
357
- }
358
-
359
- // Add guest session messages (multiplayer — someone typed into your session)
360
- if (guestMessages.length > 0) {
361
- display += `\n\n**🎤 ${guestMessages.length} guest message${guestMessages.length > 1 ? 's' : ''} in your session:**`;
362
- guestMessages.forEach(m => {
363
- const time = new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
364
- display += `\n• [${time}] @${inertField(m.from, 40)}: ${inertField(m.message, 80)}`;
365
- });
366
- display += `\n_Use vibe_guest with action "ack" to clear after reading._`;
367
- }
368
-
369
- // Show pair status if paired with someone
370
- if (pairStatus) {
371
- const mode = pairStatus.mode || 'coding';
372
- display += `\n\n**🔗 Paired with @${pairStatus.partner}** (${mode})`;
373
- display += `\n_Session sharing active. Use vibe_guest to exchange messages._`;
374
- }
375
-
376
- // Add memory context for returning users
377
- if (threads.length > 0) {
378
- const recentThreads = threads.slice(0, 3);
379
- const names = recentThreads.map(t => `@${t.handle}`).join(', ');
380
- display += `\n\n💭 **${threads.length}** people in memory · ${names}`;
381
- }
316
+ if (updateNotice) display += updateNotice;
382
317
 
383
- // Add update notice if we just auto-updated
384
- if (updateNotice) {
385
- display += updateNotice;
386
- }
318
+ display += `\n\nvibe inbox · vibe people · vibe dm @handle "…"`;
387
319
 
388
- // Step 6: Show rotating tips about features.
389
- // A tip may only name a tool REGISTERED in a default session (#9.2) the
390
- // old set advertised "vibe stuck" / "vibe available" / "vibe context" /
391
- // "start presence monitor", none of which exist, so every rotating tip
392
- // told users to run a command that fails. If a tip's tool ever moves
393
- // behind VIBE_EXTRAS, the tip moves with it.
394
- const tips = [
395
- '💡 **Tip:** Say "who\'s around?" — vibe_who shows who has a live heartbeat right now.',
396
- '💡 **Tip:** Say "message @handle ..." to DM someone — replies land in your inbox across sessions.',
397
- '💡 **Tip:** Say "check my vibe inbox" any time — messages wait for you between sessions.',
398
- '💡 **Tip:** Run "npx slashvibe-mcp hook install" so waiting messages appear when your next Claude session starts.'
399
- ];
400
- const tipIndex = Math.floor(Date.now() / 60000) % tips.length; // Rotate every minute
401
- display += `\n\n---\n${tips[tipIndex]}`;
402
-
403
- // Build response with hints for structured dashboard flow
320
+ // ── THE RESPONSE ────────────────────────────────────────────────────
321
+ // The payload obeys the SAME contracts as the screen (review P1): a host
322
+ // and a model read this, so a body withheld from the display but shipped
323
+ // here is not withheld at all, and a handle chosen here is still a chosen
324
+ // handle. What ships is what the screen states counts, and the threads
325
+ // waiting with the id needed to answer one exactly. Whoever is online is
326
+ // vibe_who's answer; whoever to talk to is the person's decision.
404
327
  const response = { display };
405
328
 
406
- // === ENRICHED DATA ===
407
- // Include full online users list so Claude doesn't need to call vibe_who
408
- response.onlineUsers = others.map(u => ({
409
- handle: u.handle,
410
- hereNow: isHereNow(u),
411
- building: (u.one_liner || u.note) ? inertField(u.one_liner || u.note) : null,
412
- status: u.status ? inertField(u.status, 30) : null,
413
- lastActive: u.lastSeen ? new Date(u.lastSeen).toISOString() : null
414
- }));
415
-
416
- // Include unread thread summaries so Claude doesn't need to call vibe_inbox
417
- const unreadSenders = inboxThreads.filter(t => t.unread > 0);
418
- response.unreadThreads = unreadSenders.map(t => ({
329
+ response.unread = inboxRead ? unreadCount : null; // null = not read, never 0
330
+ response.here = presenceRead ? hereCount : null; // null = not read, never 0
331
+ response.waiting = unreadSenders.slice(0, 5).map((t) => ({
419
332
  handle: t.handle,
420
333
  unread: t.unread,
421
- preview: t.lastMessage ? t.lastMessage.slice(0, 80) : null,
422
- isAgent: t.isAgent || false
334
+ lastMessageId: t.lastMessageId || null,
423
335
  }));
424
336
 
425
- // Include guest session messages (multiplayer)
426
- if (guestMessages.length > 0) {
427
- response.guestMessages = guestMessages.map(m => ({
428
- from: m.from,
429
- message: m.message,
430
- timestamp: m.timestamp,
431
- id: m.id
432
- }));
433
- }
434
-
435
- // Include pair status if paired
436
- if (pairStatus) {
437
- response.pairStatus = {
438
- paired: true,
439
- partner: pairStatus.partner,
440
- mode: pairStatus.mode,
441
- startedAt: pairStatus.startedAt
442
- };
443
- }
444
-
445
-
446
- // Determine session state and suggest appropriate flow
447
- let suggestion = null;
448
-
449
- if (unreadCount >= 5) {
450
- // Many unread - suggest triage
451
- response.hint = 'structured_triage_recommended';
452
- response.unread_count = unreadCount;
453
- } else if (others.length === 0 && unreadCount === 0) {
454
- // Empty room - suggest discovery or invite
455
- response.hint = 'suggest_discovery';
456
- response.reason = 'empty_room';
457
- } else if (others.length > 0) {
458
- // People around - check for interesting ones
459
- const interesting = hereNow.find(u => {
460
- const age = Date.now() - u.lastSeen;
461
- return age < 5 * 60 * 1000; // Active in last 5 min
462
- });
463
- if (interesting) {
464
- suggestion = {
465
- handle: interesting.handle,
466
- reason: 'active_now',
467
- context: interesting.note || interesting.one_liner || 'Building something'
468
- };
469
- response.hint = 'surprise_suggestion';
470
- response.suggestion = suggestion;
471
- }
472
- }
473
-
474
- // Add guided mode actions for AskUserQuestion rendering
475
- const onlineHandles = others.map(u => u.handle);
476
- let actionList;
477
-
478
- if (others.length === 0 && unreadCount === 0) {
479
- // Empty room
480
- actionList = actions.emptyRoom({ workContext });
481
- } else {
482
- // Normal dashboard
483
- actionList = actions.dashboard({
484
- unreadCount,
485
- onlineUsers: onlineHandles,
486
- suggestion,
487
- workContext
488
- });
489
- }
490
-
491
- response.actions = formatActions(actionList);
492
-
493
- // ═══════════════════════════════════════════════════════════════════════
494
- // WORK CONTEXT: Include in response for Claude to use
495
- // ═══════════════════════════════════════════════════════════════════════
496
- if (workContext?.suggestions?.brief) {
497
- response.workContext = {
498
- summary: workContext.suggestions.brief,
499
- detailed: workContext.suggestions.detailed,
500
- project: workContext.project?.name,
501
- branch: workContext.git?.branch,
502
- recentCommit: workContext.git?.recentCommits?.[0]?.message || null,
503
- hasUncommitted: workContext.git?.hasUncommitted || false
504
- };
505
- }
337
+ // Work context is deliberately NOT returned here (review P1): the response
338
+ // is exactly what the screen states. The local context still does its real
339
+ // job above it sets this session's presence note so other people see what
340
+ // you are working on — which is a side effect, not a payload.
506
341
 
507
342
  return response;
508
343
  }
package/tools/token.js CHANGED
@@ -82,9 +82,12 @@ hands you a new token, paste it here.`
82
82
  // Remove old keypair (security improvement). Remember whether one existed:
83
83
  // the success copy below claims "old local keys removed" and must only say
84
84
  // that when it actually happened.
85
- const removedKeypair = config.hasKeypair();
86
- if (removedKeypair) {
85
+ // Checked AFTER, not before: "they existed and we called remove" is not the
86
+ // same fact as "they are gone", and until this round they were not gone.
87
+ let removedKeypair = false;
88
+ if (config.hasKeypair()) {
87
89
  config.removeKeypair();
90
+ removedKeypair = !config.hasKeypair();
88
91
  }
89
92
 
90
93
  // Send initial heartbeat
package/version.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
- "version": "0.8.21",
3
- "updated": "2026-08-31",
4
- "changelog": "People. You can now put yourself on a list of people who chose to be findable \u2014 say vibe list me \"what you're building\" \u2014 and browse it with vibe people. It is opt-in, it is not the same as vibe who (who's present this moment), and you can take yourself off any time with vibe unlist me. Nothing lists you automatically, nothing ranks or recommends anyone, and nothing picks who you message: you choose someone because their work makes you curious.",
2
+ "version": "0.8.23",
3
+ "updated": "2026-09-01",
4
+ "changelog": "The install tells the truth about the room. Setup used to say “0 builders online” to every newcomer, because the roster is private before you sign in and it was counting names it was never given; now it reads the count the server actually serves, and after sign-in it lists real people and what they are working on. And a first message to someone before you have signed in no longer asks GitHub to sign you in as them.",
5
5
  "features": [
6
- "vibe people \u2014 everyone who chose to be findable, online or not; flat list, no ranking, no suggestions",
7
- "vibe list me \"building \u2026\" \u2014 put yourself on the list with what you're working on; says plainly that anyone signed in can then find and DM you",
8
- "vibe unlist me \u2014 take yourself off; conversations untouched",
9
- "Listing reads the list back before claiming it worked, and says which actually happened",
10
- "Other people's words render inert: no HTML, no markdown styling, no forged rows \u2014 and no censoring either"
6
+ "Setup says how many people are here now never a zero it did not measure",
7
+ "After sign-in, setup lists the people actually here and what they are working on",
8
+ "Messaging @someone before sign-in starts your own sign-in, not one for them",
9
+ "Setup's quick start is a sentence you can say, not a command to learn"
11
10
  ],
12
11
  "deprecated": [],
13
12
  "breaking": false,