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 +94 -12
- package/index.js +11 -2
- package/package.json +2 -2
- package/setup.js +34 -22
- package/store/api.js +76 -6
- package/store/local.js +116 -12
- package/store/profiles.js +29 -1
- package/tools/inbox.js +14 -2
- package/tools/init.js +6 -3
- package/tools/reply.js +5 -1
- package/tools/start.js +85 -250
- package/tools/token.js +5 -2
- package/version.json +7 -8
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
|
-
|
|
88
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
100
|
-
privateKey: config.privateKey
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.23",
|
|
4
4
|
"mcpName": "io.github.vibecodinginc/vibe",
|
|
5
|
-
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor)
|
|
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
|
-
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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}
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
267
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 >
|
|
346
|
-
display += `\
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
if (updateNotice) {
|
|
385
|
-
display += updateNotice;
|
|
386
|
-
}
|
|
318
|
+
display += `\n\nvibe inbox · vibe people · vibe dm @handle "…"`;
|
|
387
319
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
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
|
-
//
|
|
407
|
-
|
|
408
|
-
response.
|
|
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
|
-
|
|
422
|
-
isAgent: t.isAgent || false
|
|
334
|
+
lastMessageId: t.lastMessageId || null,
|
|
423
335
|
}));
|
|
424
336
|
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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.
|
|
3
|
-
"updated": "2026-
|
|
4
|
-
"changelog": "
|
|
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
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,
|