slashvibe-mcp 0.8.20 → 0.8.22

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/incoming.js CHANGED
@@ -111,4 +111,43 @@ function inertField(text, maxLen = 80) {
111
111
  return flat.length > maxLen ? flat.slice(0, maxLen - 1) + '\u2026' : flat;
112
112
  }
113
113
 
114
- module.exports = { renderIncoming, neutralize, scrub, inertField, MSG_OPEN, MSG_CLOSE, MAX_BODY };
114
+ /**
115
+ * inertField, plus the markup a rendered surface can act on.
116
+ *
117
+ * inertField defangs terminal/agent structure (control chars, bidi, backticks,
118
+ * brackets). It deliberately leaves HTML and Markdown alone, which is fine for
119
+ * a plain terminal line and NOT fine anywhere a client renders markup: `<br>`
120
+ * forges a new row, `<details>` hides text, `**x**` and a leading bullet fake
121
+ * structure (review P1 on the people list). This makes those inert while
122
+ * keeping every word visible — defanged, never censored.
123
+ */
124
+ function inertMarkup(text, maxLen = 80) {
125
+ return inertField(text, maxLen)
126
+ .replaceAll('<', '\u2039') // ‹ — no HTML element can form
127
+ .replaceAll('>', '\u203a') // ›
128
+ .replaceAll('*', '\u2217') // ∗ — no bold/italic/bullet
129
+ .replaceAll('|', '\u2502') // │ — no table row
130
+ .replace(/^[\s\u2022•\-+#]+/, '') // no forged list item or heading
131
+ // Emphasis pairs across the WHOLE rendered document, not within one field
132
+ // (review P1): a lone `_` here can close an emphasis the surface itself
133
+ // opened, or pair with another row. So every `_` and `~` in foreign PROSE
134
+ // is defanged unconditionally. Identity is handled separately — see
135
+ // inertIdentity, which preserves a handle exactly by other means.
136
+ .replace(/_/g, '\u2017')
137
+ .replace(/~/g, '\u223c');
138
+ }
139
+
140
+ /**
141
+ * A handle must render EXACTLY — mangling someone's identity to defang markup
142
+ * is its own dishonesty. So it is emitted inside an inline code span, where
143
+ * no emphasis, HTML or bullet can form, and where the fence itself cannot be
144
+ * broken (inertField has already neutralized backticks in foreign text).
145
+ */
146
+ function inertIdentity(text, maxLen = 40) {
147
+ return '`' + inertField(text, maxLen)
148
+ .replaceAll('<', '\u2039')
149
+ .replaceAll('>', '\u203a')
150
+ .replace(/^[\s\u2022•\-+#]+/, '') + '`';
151
+ }
152
+
153
+ module.exports = { renderIncoming, neutralize, scrub, inertField, inertMarkup, inertIdentity, MSG_OPEN, MSG_CLOSE, MAX_BODY };
package/index.js CHANGED
@@ -52,7 +52,16 @@ const SERVER_CAPABILITIES = {
52
52
  // ambient footer performs platform requests, so remember/reflect/call and
53
53
  // the manifest never carry it.
54
54
  const SKIP_FOOTER_TOOLS = ['vibe_init', 'vibe_doctor', 'vibe_test', 'vibe_update',
55
- 'vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call'];
55
+ 'vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call',
56
+ // The people actions each end in ONE obvious next action; the ambient
57
+ // footer would stack a second one AND name a specific recipient to reply
58
+ // to — the choosing is the human's, so the footer stays off here.
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'];
56
65
 
57
66
  // Progressive disclosure: only these tools are visible before authentication
58
67
  // After auth, the full toolset is revealed via tools/list_changed notification
@@ -326,6 +335,14 @@ const kernelTools = {
326
335
  vibe_remember: require('./tools/remember'),
327
336
  vibe_reflect: require('./tools/reflect'),
328
337
  vibe_call: require('./tools/call'),
338
+
339
+ // ── People (opt-in discovery, platform#345 / vibe-mcp#28) ──────────────
340
+ // `vibe who` is who is present NOW; `vibe people` is who chose to be
341
+ // findable, online or not. Listing is always the person's own act — no
342
+ // path anywhere may set it for them.
343
+ vibe_people: require('./tools/people'),
344
+ vibe_list_me: require('./tools/list-me'),
345
+ vibe_unlist_me: require('./tools/unlist-me'),
329
346
  };
330
347
 
331
348
  // ─── Extras ──────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) \u2014 the /vibe kernel",
6
6
  "main": "index.js",
@@ -109,8 +109,10 @@
109
109
  "tools/inbox.js",
110
110
  "tools/init.js",
111
111
  "tools/intro.js",
112
+ "tools/list-me.js",
112
113
  "tools/mind.js",
113
114
  "tools/patterns.js",
115
+ "tools/people.js",
114
116
  "tools/play.js",
115
117
  "tools/poem.js",
116
118
  "tools/reflect.js",
@@ -122,6 +124,7 @@
122
124
  "tools/summarize.js",
123
125
  "tools/test.js",
124
126
  "tools/token.js",
127
+ "tools/unlist-me.js",
125
128
  "tools/update.js",
126
129
  "tools/weave.js",
127
130
  "tools/who.js",
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
14
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
15
- const VERBS = ['vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call', 'vibe_dm', 'vibe_inbox', 'vibe_reply'];
15
+ const VERBS = ['vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call', 'vibe_dm', 'vibe_inbox', 'vibe_reply', 'vibe_people', 'vibe_list_me', 'vibe_unlist_me'];
16
16
  const LEGAL = new Set(['granted', 'available', 'off', 'unavailable']);
17
17
 
18
18
  function rpc(child, msg) {
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;
450
462
  }
451
463
  }
452
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 };
483
+ }
484
+ }
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
 
@@ -1315,8 +1383,70 @@ async function setNotificationPace(pace) {
1315
1383
  }
1316
1384
  }
1317
1385
 
1386
+ /**
1387
+ * The people list (opt-in discovery, platform#345). Two contained calls:
1388
+ * flip your OWN listed flag, and read the people who chose to be findable.
1389
+ * Nothing here ranks, recommends, or lists anyone automatically.
1390
+ */
1391
+ /**
1392
+ * The two decisions the people helpers make, as pure functions so the pins
1393
+ * exercise the REAL logic rather than a stubbed result (review P2).
1394
+ */
1395
+ function interpretListedResponse(result, want) {
1396
+ if (!result || result.success === false) {
1397
+ return { ok: false, error: result?.error || 'request_failed', message: result?.message };
1398
+ }
1399
+ const echoed = result?.user?.listed;
1400
+ if (typeof echoed !== 'boolean') {
1401
+ return { ok: false, error: 'unconfirmed', message: 'the server did not say whether the change took' };
1402
+ }
1403
+ if (echoed !== want) {
1404
+ return { ok: false, error: 'unconfirmed', message: `the server reports listed=${echoed}` };
1405
+ }
1406
+ return { ok: true, listed: echoed };
1407
+ }
1408
+
1409
+ function interpretDirectoryResponse(result) {
1410
+ if (!result || result.success === false) {
1411
+ return { ok: false, error: result?.error || 'request_failed', message: result?.message };
1412
+ }
1413
+ if (!Array.isArray(result.listings)) {
1414
+ return { ok: false, error: 'malformed_response', message: 'the list came back without entries' };
1415
+ }
1416
+ return { ok: true, listings: result.listings, count: result.count, note: result.note };
1417
+ }
1418
+
1419
+ async function setListed(listed, building) {
1420
+ const want = listed === true;
1421
+ const body = { listed: want };
1422
+ // `building` rides along ONLY when the person supplied it with the action —
1423
+ // the platform's contained self-profile write owns both fields.
1424
+ if (typeof building === 'string' && building.trim()) body.building = building.trim();
1425
+ try {
1426
+ // The SERVER'S ECHO is the fact, not the absence of an error (review P1).
1427
+ return interpretListedResponse(await request('POST', '/api/users', body, { auth: true }), want);
1428
+ } catch (e) {
1429
+ return { ok: false, error: 'transport_failed', message: e.message };
1430
+ }
1431
+ }
1432
+
1433
+ async function getPeople() {
1434
+ try {
1435
+ // A response without a listings ARRAY is malformed — reporting it as an
1436
+ // empty list would turn a read failure into "nobody is here" (review P2).
1437
+ return interpretDirectoryResponse(await request('GET', '/api/directory'));
1438
+ } catch (e) {
1439
+ return { ok: false, error: 'transport_failed', message: e.message };
1440
+ }
1441
+ }
1442
+
1318
1443
  module.exports = {
1319
1444
  setNotificationPace,
1445
+ // People (opt-in discovery)
1446
+ setListed,
1447
+ getPeople,
1448
+ interpretListedResponse,
1449
+ interpretDirectoryResponse,
1320
1450
  // Session
1321
1451
  registerSession,
1322
1452
  setSessionId,
@@ -1325,6 +1455,7 @@ module.exports = {
1325
1455
  // Presence
1326
1456
  heartbeat,
1327
1457
  getActiveUsers,
1458
+ getActiveUsersResult,
1328
1459
  setVisibility,
1329
1460
  sendTypingIndicator,
1330
1461
  getTypingUsers,
@@ -1332,6 +1463,7 @@ module.exports = {
1332
1463
  // Messages
1333
1464
  sendMessage,
1334
1465
  getInbox,
1466
+ getInboxResult,
1335
1467
  getRawInbox,
1336
1468
  getUnreadCount,
1337
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,