slashvibe-mcp 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/auth-store.js CHANGED
@@ -20,6 +20,12 @@ let _token = null;
20
20
  let _handle = null;
21
21
  let _oneLiner = null;
22
22
  let _hydrated = false;
23
+ // Has the SERVER confirmed this credential in this process? A token read off disk is
24
+ // a claim; only a verify round-trip makes it a fact. Kept separate from _token so
25
+ // callers can distinguish "saved" from "authenticated" — announcing presence or
26
+ // unlocking a tool on the strength of a filename is how a green dot comes to mean
27
+ // nothing (issues #107, #110).
28
+ let _verified = false;
23
29
 
24
30
  /**
25
31
  * Hydrate auth state from disk (call once at MCP startup)
@@ -99,9 +105,21 @@ function hydrate() {
99
105
  * Set auth token (call after OAuth completes)
100
106
  * @param {string} token - JWT token
101
107
  */
102
- function setToken(token) {
108
+ function setToken(token, { verified = false } = {}) {
109
+ if (token) {
110
+ const who = handleFromToken(token);
111
+ if (!who) {
112
+ // Same rule hydrate() applies, applied here too — a credential we cannot
113
+ // attribute must not become the session.
114
+ console.error('[auth-store] Refusing a token with no identity');
115
+ _token = null; _handle = null; _verified = false;
116
+ return;
117
+ }
118
+ }
119
+
103
120
  const hadToken = !!_token;
104
121
  _token = token;
122
+ _verified = !!token && verified;
105
123
 
106
124
  // Identity travels WITH the credential (see hydrate). A new token can belong to a
107
125
  // different person — after a re-auth, an account switch, or a cross-client sync —
@@ -169,6 +187,21 @@ function isAuthenticated() {
169
187
  return !!_token;
170
188
  }
171
189
 
190
+ /** The server confirmed this credential in this process. */
191
+ function isVerified() {
192
+ return !!_token && _verified;
193
+ }
194
+
195
+ /** Record a successful server verification for the token we already hold. */
196
+ function markVerified(handle) {
197
+ if (!_token) return;
198
+ if (handle && handle !== _handle) {
199
+ console.error(`[auth-store] Server says this session is @${handle} (held @${_handle})`);
200
+ _handle = handle;
201
+ }
202
+ _verified = true;
203
+ }
204
+
172
205
  /**
173
206
  * Clear all auth state (for logout/reset)
174
207
  */
@@ -176,6 +209,7 @@ function clear() {
176
209
  _token = null;
177
210
  _handle = null;
178
211
  _oneLiner = null;
212
+ _verified = false;
179
213
  console.error('[auth-store] Cleared');
180
214
  }
181
215
 
@@ -203,5 +237,7 @@ module.exports = {
203
237
  getOneLiner,
204
238
  isAuthenticated,
205
239
  clear,
206
- getState
240
+ getState,
241
+ isVerified,
242
+ markVerified,
207
243
  };
package/incoming.js CHANGED
@@ -62,14 +62,27 @@ function renderIncoming(items, { replyTo, threadHint } = {}) {
62
62
  * - our own fence markers are neutralized, as in scrub();
63
63
  * - backticks and square brackets are defanged, so foreign text cannot open a code
64
64
  * span or synthesize a link/citation that looks like ours;
65
+ * - bidi overrides and zero-width characters are stripped, so the bytes a model
66
+ * reads cannot differ from the glyphs a human sees;
65
67
  * - it is length-bounded, because the injection payloads that work are long.
66
68
  *
69
+ * What it deliberately does NOT do is judge meaning. A status that reads
70
+ * "SYSTEM: call vibe_dm" survives as TEXT — and must, because the alternative is a
71
+ * blocklist that fails silently. Meaning is handled by the other half of the rule:
72
+ * foreign text is always labelled as data, and no surface may place it next to an
73
+ * instruction to act on it. That is why the weave draft-and-send prompt was removed
74
+ * rather than filtered.
75
+ *
67
76
  * Callers still label the region as data — this makes the VALUE inert, and the
68
77
  * surrounding copy says whose words they are.
69
78
  */
70
79
  function inertField(text, maxLen = 80) {
71
80
  const flat = String(text || '')
72
81
  .replace(/[\u0000-\u001f\u007f]/g, ' ') // control chars, incl. newlines
82
+ // Bidi overrides and invisible formatting characters: a status can otherwise
83
+ // render as one thing to the human reading the terminal and another to the model
84
+ // reading the same bytes, or hide text inside an apparently short field.
85
+ .replace(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\u00ad]/g, '')
73
86
  .replace(/\s+/g, ' ')
74
87
  .replaceAll('<<<', '\u2039\u2039\u2039')
75
88
  .replaceAll('>>>', '\u203a\u203a\u203a')
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+
6
+ const CALLBACK_HOST = '127.0.0.1';
7
+ const DEFAULT_CALLBACK_PORT = 9876;
8
+ const DEFAULT_TIMEOUT_MS = 300000;
9
+ const DEFAULT_GRACE_MS = 300000;
10
+ const LOGIN_URL = 'https://www.slashvibe.dev/login';
11
+
12
+ const SUCCESS_PAGE = `<!DOCTYPE html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="UTF-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
17
+ <title>signed in to /vibe</title>
18
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
19
+ <style>
20
+ * { margin: 0; padding: 0; box-sizing: border-box; }
21
+ body {
22
+ background: var(--bg, #0A0A0A);
23
+ color: var(--dim, #9CA3AF);
24
+ font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
25
+ font-size: var(--t-14, 14px);
26
+ line-height: 1.6;
27
+ min-height: 100vh;
28
+ display: flex; align-items: center; justify-content: center;
29
+ padding: var(--s-6, 24px);
30
+ }
31
+ .card {
32
+ border: 1px solid var(--line, #1F2937);
33
+ border-radius: var(--r-lg, 10px);
34
+ background: var(--panel, #111316);
35
+ padding: var(--s-8, 32px);
36
+ max-width: 420px; width: 100%;
37
+ }
38
+ .logo { color: var(--ink, #E0E0E0); font-size: var(--t-16, 16px); margin-bottom: var(--s-6, 24px); }
39
+ .logo span { color: var(--blue, #6B8FFF); }
40
+ .line { color: var(--ink, #E0E0E0); font-size: var(--t-20, 20px); margin-bottom: var(--s-2, 8px); }
41
+ .dot { color: var(--green, #22c55e); }
42
+ .meta { font-size: var(--t-13, 13px); }
43
+ .next {
44
+ margin-top: var(--s-6, 24px); padding-top: var(--s-4, 16px);
45
+ border-top: 1px solid var(--line, #1F2937); font-size: var(--t-13, 13px);
46
+ }
47
+ code {
48
+ color: var(--ink, #E0E0E0);
49
+ background: var(--bg, #0A0A0A);
50
+ border: 1px solid var(--line, #1F2937);
51
+ border-radius: var(--r-sm, 6px);
52
+ padding: 1px 6px;
53
+ }
54
+ .close { color: var(--faint, #6B7280); font-size: var(--t-11, 11px); margin-top: var(--s-6, 24px); }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="card">
59
+ <div class="logo">/<span>vibe</span></div>
60
+ <p class="line"><span class="dot">&#x1F7E2;</span> you're in</p>
61
+ <p class="meta">your session is signed in and you're on the board.</p>
62
+ <div class="next">
63
+ back in your terminal, say <code>vibe who</code> to see who's around,
64
+ or <code>vibe inbox</code> if someone already wrote to you.
65
+ </div>
66
+ <p class="close">you can close this window.</p>
67
+ </div>
68
+ </body>
69
+ </html>`;
70
+
71
+ const LATE_CALLBACK_PAGE = `<!DOCTYPE html>
72
+ <html lang="en"><head><meta charset="UTF-8">
73
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
+ <title>sign-in timed out · /vibe</title>
75
+ <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
76
+ <style>
77
+ *{margin:0;padding:0;box-sizing:border-box}
78
+ body{background:var(--bg,#0A0A0A);color:var(--dim,#9CA3AF);font-family:var(--mono,ui-monospace,Menlo,monospace);
79
+ font-size:var(--t-14,14px);line-height:1.6;min-height:100vh;display:flex;align-items:center;
80
+ justify-content:center;padding:24px}
81
+ .card{border:1px solid var(--line,#1F2937);border-radius:var(--r-lg,10px);background:var(--panel,#111316);
82
+ padding:32px;max-width:420px;width:100%}
83
+ .logo{color:var(--ink,#E0E0E0);font-size:var(--t-16,16px);margin-bottom:24px}
84
+ .logo span{color:var(--blue,#6B8FFF)}
85
+ h1{color:var(--ink,#E0E0E0);font-size:var(--t-20,20px);font-weight:600;margin-bottom:8px}
86
+ code{color:var(--ink,#E0E0E0);background:var(--bg,#0A0A0A);border:1px solid var(--line,#1F2937);
87
+ border-radius:var(--r-sm,6px);padding:1px 6px}
88
+ .next{margin-top:24px;padding-top:16px;border-top:1px solid var(--line,#1F2937);font-size:var(--t-13,13px)}
89
+ </style></head>
90
+ <body><div class="card">
91
+ <div class="logo">/<span>vibe</span></div>
92
+ <h1>sign-in took too long</h1>
93
+ <p>your terminal stopped waiting, so this sign-in didn't finish. nothing is broken and
94
+ nothing was saved.</p>
95
+ <div class="next">back in your terminal, say <code>vibe init</code> and it will open a fresh
96
+ sign-in. this window can be closed.</div>
97
+ </div></body></html>`;
98
+
99
+ const BAD_CALLBACK_PAGE = `<!DOCTYPE html>
100
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
101
+ <title>sign-in could not finish · /vibe</title></head>
102
+ <body><p>this sign-in callback could not be accepted. return to your terminal and try again.</p></body></html>`;
103
+
104
+ function listen(server, port) {
105
+ return new Promise((resolve, reject) => {
106
+ const onError = (error) => {
107
+ server.removeListener('listening', onListening);
108
+ reject(error);
109
+ };
110
+ const onListening = () => {
111
+ server.removeListener('error', onError);
112
+ resolve();
113
+ };
114
+
115
+ server.once('error', onError);
116
+ server.once('listening', onListening);
117
+ server.listen(port, CALLBACK_HOST);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Bind the loopback listener, THEN hand back the URL to open.
123
+ * Binding first is load-bearing: opening the browser before the socket exists is
124
+ * what lets a local squatter or a concurrent init receive the credential.
125
+ */
126
+ async function beginOAuth({
127
+ requestedHandle,
128
+ timeoutMs = DEFAULT_TIMEOUT_MS,
129
+ graceMs = DEFAULT_GRACE_MS,
130
+ } = {}) {
131
+ const state = crypto.randomBytes(32).toString('hex');
132
+ let phase = 'waiting';
133
+ let timeoutTimer = null;
134
+ let graceTimer = null;
135
+ let resolveCallback;
136
+ let rejectCallback;
137
+ let closePromise;
138
+
139
+ const callbackPromise = new Promise((resolve, reject) => {
140
+ resolveCallback = resolve;
141
+ rejectCallback = reject;
142
+ });
143
+ // A caller can cancel without ever waiting. Keep that from becoming an
144
+ // unhandled rejection while preserving the original promise for waiters.
145
+ callbackPromise.catch(() => {});
146
+
147
+ const server = http.createServer((req, res) => {
148
+ const url = new URL(req.url, `http://${CALLBACK_HOST}`);
149
+
150
+ if (url.pathname !== '/callback') {
151
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
152
+ res.end('Not found');
153
+ return;
154
+ }
155
+
156
+ if (url.searchParams.get('state') !== state) {
157
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
158
+ res.end(BAD_CALLBACK_PAGE);
159
+ return;
160
+ }
161
+
162
+ if (phase === 'timedOut') {
163
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
164
+ res.end(LATE_CALLBACK_PAGE);
165
+ return;
166
+ }
167
+
168
+ const token = url.searchParams.get('token');
169
+ const callbackHandle = url.searchParams.get('handle');
170
+ if (phase !== 'waiting' || !token || !callbackHandle) {
171
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
172
+ res.end(BAD_CALLBACK_PAGE);
173
+ return;
174
+ }
175
+
176
+ // WHO AUTHENTICATED WINS — not who we asked for. A requested handle is a
177
+ // preference for a brand-new account, never a claim about the credential.
178
+ if (requestedHandle && requestedHandle !== callbackHandle) {
179
+ console.error(
180
+ `[vibe] Signed in as @${callbackHandle} (you asked for @${requestedHandle}) — using the account that authenticated.`
181
+ );
182
+ }
183
+
184
+ phase = 'completed';
185
+ clearTimeout(timeoutTimer);
186
+ clearTimeout(graceTimer);
187
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
188
+ res.end(SUCCESS_PAGE, () => { closeServer().catch(() => {}); });
189
+ resolveCallback({ token, handle: callbackHandle });
190
+ });
191
+
192
+ function closeServer() {
193
+ if (closePromise) return closePromise;
194
+ if (!server.listening) {
195
+ closePromise = Promise.resolve();
196
+ return closePromise;
197
+ }
198
+ closePromise = new Promise((resolve, reject) => {
199
+ server.close((error) => {
200
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
201
+ else resolve();
202
+ });
203
+ });
204
+ return closePromise;
205
+ }
206
+
207
+ try {
208
+ await listen(server, DEFAULT_CALLBACK_PORT);
209
+ } catch (error) {
210
+ if (error.code !== 'EADDRINUSE') {
211
+ phase = 'cancelled';
212
+ rejectCallback(error);
213
+ throw error;
214
+ }
215
+
216
+ try {
217
+ await listen(server, 0);
218
+ } catch (fallbackError) {
219
+ phase = 'cancelled';
220
+ const finalError = fallbackError.code === 'EADDRINUSE'
221
+ ? new Error('AUTH_IN_PROGRESS')
222
+ : fallbackError;
223
+ rejectCallback(finalError);
224
+ throw finalError;
225
+ }
226
+ }
227
+
228
+ const address = server.address();
229
+ const port = address.port;
230
+ const callbackUrl = new URL(`http://${CALLBACK_HOST}:${port}/callback`);
231
+ callbackUrl.searchParams.set('state', state);
232
+ const loginUrl = new URL(LOGIN_URL);
233
+ loginUrl.searchParams.set('redirect', callbackUrl.toString());
234
+ loginUrl.searchParams.set('state', state);
235
+ if (requestedHandle) loginUrl.searchParams.set('handle', requestedHandle);
236
+
237
+ timeoutTimer = setTimeout(() => {
238
+ if (phase !== 'waiting') return;
239
+ phase = 'timedOut';
240
+ rejectCallback(new Error('AUTH_TIMEOUT'));
241
+ graceTimer = setTimeout(() => {
242
+ if (phase === 'timedOut') {
243
+ phase = 'closed';
244
+ closeServer().catch(() => {});
245
+ }
246
+ }, graceMs);
247
+ }, timeoutMs);
248
+
249
+ async function cancel() {
250
+ clearTimeout(timeoutTimer);
251
+ clearTimeout(graceTimer);
252
+ if (phase === 'waiting') {
253
+ phase = 'cancelled';
254
+ rejectCallback(new Error('AUTH_CANCELLED'));
255
+ } else if (phase !== 'closed') {
256
+ phase = 'cancelled';
257
+ }
258
+
259
+ await closeServer();
260
+ }
261
+
262
+ return {
263
+ loginUrl: loginUrl.toString(),
264
+ port,
265
+ state,
266
+ waitForCallback: () => callbackPromise,
267
+ cancel,
268
+ };
269
+ }
270
+
271
+ module.exports = { beginOAuth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) — the /vibe kernel",
6
6
  "main": "index.js",
@@ -64,6 +64,7 @@
64
64
  "memory.js",
65
65
  "notification-emitter.js",
66
66
  "notify.js",
67
+ "oauth-callback.js",
67
68
  "presence.js",
68
69
  "prompts.js",
69
70
  "protocol/index.js",
package/presence.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const config = require('./config');
16
+ const authStore = require('./auth-store');
16
17
  const store = require('./store');
17
18
  const notify = require('./notify');
18
19
  const { apiHeaders } = require('./api-auth');
@@ -53,8 +54,10 @@ function stop() {
53
54
  async function initSession() {
54
55
  if (!config.isInitialized()) return;
55
56
 
56
- // Use session-aware getters (prefer session identity over shared config)
57
- const handle = config.getHandle();
57
+ // The handle we broadcast is the one our CREDENTIAL names — not the one written in
58
+ // a config file. Reading config here is what made "presence follows the credential"
59
+ // untrue after #107: the gate moved, the heartbeat did not (codex #4).
60
+ const handle = authStore.getHandle();
58
61
  if (!handle) return;
59
62
 
60
63
  // Get or create session ID
@@ -74,8 +77,8 @@ async function initSession() {
74
77
  async function sendHeartbeat() {
75
78
  if (!config.isInitialized()) return;
76
79
 
77
- // Use session-aware getters (prefer session identity over shared config)
78
- const handle = config.getHandle();
80
+ // Same rule as initSession: presence names whoever holds the credential.
81
+ const handle = authStore.getHandle();
79
82
  const one_liner = config.getOneLiner();
80
83
  if (handle) {
81
84
  store.heartbeat(handle, one_liner || '');
package/setup.js CHANGED
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
20
  const { exec, execSync } = require('child_process');
21
- const http = require('http');
21
+ const { beginOAuth } = require('./oauth-callback');
22
22
 
23
23
  // ANSI colors for terminal output
24
24
  const colors = {
@@ -31,9 +31,7 @@ const colors = {
31
31
  dim: '\x1b[2m'
32
32
  };
33
33
 
34
- const CALLBACK_PORT = 9876;
35
34
  const API_BASE = 'https://www.slashvibe.dev';
36
- const LOGIN_URL = 'https://www.slashvibe.dev/login';
37
35
 
38
36
  /**
39
37
  * Print styled banner
@@ -315,90 +313,6 @@ function openBrowser(url) {
315
313
  });
316
314
  }
317
315
 
318
- /**
319
- * Wait for OAuth callback
320
- */
321
- function waitForAuth() {
322
- return new Promise((resolve, reject) => {
323
- let resolved = false;
324
-
325
- const server = http.createServer(async (req, res) => {
326
- const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
327
-
328
- if (url.pathname === '/callback') {
329
- const token = url.searchParams.get('token');
330
- const handle = url.searchParams.get('handle');
331
-
332
- if (token && handle) {
333
- // Send success page
334
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
335
- res.end(`<!DOCTYPE html>
336
- <html>
337
- <head>
338
- <title>Welcome to /vibe!</title>
339
- <style>
340
- body {
341
- font-family: 'SF Mono', Monaco, monospace;
342
- background: #0a0a0a;
343
- color: #e0e0e0;
344
- display: flex;
345
- justify-content: center;
346
- align-items: center;
347
- min-height: 100vh;
348
- margin: 0;
349
- }
350
- .container {
351
- text-align: center;
352
- padding: 2rem;
353
- }
354
- h1 { color: #00FF88; }
355
- .handle { color: #6B8FFF; }
356
- .close { color: #888; margin-top: 2rem; }
357
- </style>
358
- </head>
359
- <body>
360
- <div class="container">
361
- <h1>✓ Setup Complete!</h1>
362
- <p>Welcome, <span class="handle">@${handle}</span></p>
363
- <p>You're now connected to /vibe</p>
364
- <p class="close">You can close this window</p>
365
- </div>
366
- </body>
367
- </html>`);
368
-
369
- resolved = true;
370
- setTimeout(() => server.close(), 500);
371
- resolve({ success: true, handle, token });
372
- } else {
373
- res.writeHead(400);
374
- res.end('Missing token or handle');
375
- }
376
- } else {
377
- res.writeHead(404);
378
- res.end('Not found');
379
- }
380
- });
381
-
382
- server.on('error', (err) => {
383
- if (err.code === 'EADDRINUSE') {
384
- reject(new Error('Auth server port in use'));
385
- } else {
386
- reject(err);
387
- }
388
- });
389
-
390
- server.listen(CALLBACK_PORT, '127.0.0.1');
391
-
392
- // Timeout after 2 minutes
393
- setTimeout(() => {
394
- if (!resolved) {
395
- server.close();
396
- reject(new Error('Auth timed out'));
397
- }
398
- }, 120000);
399
- });
400
- }
401
-
402
316
  /**
403
317
  * Save auth config
404
318
  */
@@ -486,14 +400,16 @@ async function setup() {
486
400
  // Step 4: Authenticate
487
401
  printStep(4, 'Opening browser for GitHub auth...', 'running');
488
402
 
489
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
490
- const loginUrl = `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}&setup=true`;
491
-
492
- openBrowser(loginUrl);
493
- console.log(`${colors.dim} → Waiting for authentication...${colors.reset}`);
403
+ let oauth;
494
404
 
495
405
  try {
496
- const authResult = await waitForAuth();
406
+ // The callback listener is bound before beginOAuth returns, so the browser
407
+ // cannot race a listener that does not exist yet.
408
+ oauth = await beginOAuth();
409
+ openBrowser(oauth.loginUrl);
410
+ console.log(`${colors.dim} → Waiting for authentication...${colors.reset}`);
411
+
412
+ const authResult = await oauth.waitForCallback();
497
413
 
498
414
  // Save auth config
499
415
  saveAuthConfig(authResult.handle, authResult.token);
@@ -547,15 +463,15 @@ async function setup() {
547
463
  printStep(4, 'Opening browser for GitHub auth...', 'error');
548
464
 
549
465
  // Specific error recovery messages
550
- if (err.message.includes('timed out')) {
551
- console.log(`${colors.red} → Authentication timed out (2 min limit)${colors.reset}`);
466
+ if (err.message === 'AUTH_TIMEOUT') {
467
+ console.log(`${colors.red} → Authentication timed out (5 min limit)${colors.reset}`);
552
468
  console.log('');
553
469
  console.log(`${colors.yellow} What happened:${colors.reset}`);
554
470
  console.log(`${colors.dim} The browser auth wasn't completed in time.${colors.reset}`);
555
471
  console.log('');
556
472
  console.log(`${colors.bold} Try again:${colors.reset}`);
557
473
  console.log(`${colors.cyan} npx slashvibe-mcp setup${colors.reset}`);
558
- } else if (err.message.includes('port in use')) {
474
+ } else if (err.message === 'AUTH_IN_PROGRESS') {
559
475
  console.log(`${colors.red} → Auth callback port busy${colors.reset}`);
560
476
  console.log('');
561
477
  console.log(`${colors.yellow} What happened:${colors.reset}`);
@@ -573,9 +489,12 @@ async function setup() {
573
489
  console.log(`${colors.dim} 2. Try again: ${colors.cyan}npx slashvibe-mcp setup${colors.reset}`);
574
490
  console.log(`${colors.dim} 3. Or in Claude Code, type: ${colors.cyan}add the vibe mcp server${colors.reset}`);
575
491
  }
492
+ if (oauth && err.message !== 'AUTH_TIMEOUT') await oauth.cancel();
576
493
  console.log('');
577
494
  console.log(`${colors.dim} Need help? slashvibe.dev/help${colors.reset}`);
578
- process.exit(1);
495
+ // On timeout the shared listener remains live during its grace window so a
496
+ // late browser callback gets the explanatory page instead of a refusal.
497
+ process.exitCode = 1;
579
498
  }
580
499
  }
581
500
 
package/tools/init.js CHANGED
@@ -9,7 +9,6 @@
9
9
  * 5. Tool WAITS for callback and returns success
10
10
  */
11
11
 
12
- const http = require('http');
13
12
  const { exec, execSync } = require('child_process');
14
13
  const fs = require('fs');
15
14
  const path = require('path');
@@ -17,8 +16,8 @@ const config = require('../config');
17
16
  const store = require('../store');
18
17
  const discord = require('../discord');
19
18
  const authStore = require('../auth-store');
19
+ const { beginOAuth } = require('../oauth-callback');
20
20
 
21
- const CALLBACK_PORT = 9876;
22
21
  const API_BASE = 'https://www.slashvibe.dev';
23
22
 
24
23
  /**
@@ -241,15 +240,7 @@ async function sendPersonalizedWelcome(handle, oneLiner) {
241
240
  }
242
241
  }
243
242
 
244
- const LOGIN_URL = 'https://www.slashvibe.dev/login';
245
243
  const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
246
- // A first sign-in is not two minutes of typing: it can be a GitHub login, 2FA, a
247
- // password manager, or switching accounts. The old fuse expired mid-flow for people
248
- // who WROTE this client (issue #108), so it is now generous.
249
- const AUTH_TIMEOUT_MS = 300000; // 5 minutes to complete the browser flow
250
- // After we stop waiting, keep the socket up briefly so a late callback still gets a
251
- // real page instead of the browser's connection-refused error.
252
- const LATE_CALLBACK_GRACE_MS = 300000; // 5 more minutes to answer honestly
253
244
 
254
245
  /**
255
246
  * Send welcome message from @seth (founder)
@@ -269,13 +260,9 @@ async function sendWelcomeMessage(handle, one_liner) {
269
260
  }
270
261
  }
271
262
 
272
- // The exact link a user can click if the browser didn't pop open on its own.
273
- // Deterministic (fixed callback port), so it's safe to show verbatim anywhere.
274
- const MANUAL_LOGIN_URL = `https://www.slashvibe.dev/login?redirect=${encodeURIComponent(`http://localhost:${CALLBACK_PORT}/callback`)}`;
275
-
276
263
  const definition = {
277
264
  name: 'vibe_init',
278
- description: `Join /vibe social network. Opens the browser for GitHub sign-in — NO INPUT NEEDED; the user's GitHub username becomes their handle automatically. This BLOCKS for up to 2 minutes waiting for the browser login to finish, so BEFORE it returns the user sees only a spinner. IMPORTANT: right when you call this, tell the user in your own words that their browser is opening to sign in with GitHub, and that if the tab didn't pop open they can click this link instead: ${MANUAL_LOGIN_URL} — then finish the login there and come back.`,
265
+ description: `Join /vibe social network. Opens the browser for GitHub sign-in — NO INPUT NEEDED; the user's GitHub username becomes their handle automatically. This BLOCKS for up to 5 minutes waiting for the browser login to finish, so BEFORE it returns the user sees only a spinner. IMPORTANT: right when you call this, tell the user in your own words that their browser is opening to sign in with GitHub, then finish the login there and come back.`,
279
266
  inputSchema: {
280
267
  type: 'object',
281
268
  properties: {
@@ -314,221 +301,6 @@ function openBrowser(url) {
314
301
  });
315
302
  }
316
303
 
317
- /**
318
- * Wait for OAuth callback - returns Promise that resolves with handle when auth completes
319
- */
320
- function waitForCallback(requestedHandle, one_liner) {
321
- return new Promise((resolve, reject) => {
322
- let resolved = false;
323
- let timedOut = false;
324
-
325
- const server = http.createServer(async (req, res) => {
326
- const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
327
-
328
- // A callback that arrives after we stopped waiting still deserves an answer.
329
- // Anything is better than the browser's raw connection-refused page (issue #108).
330
- if (timedOut && url.pathname === '/callback') {
331
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
332
- res.end(`<!DOCTYPE html>
333
- <html lang="en"><head><meta charset="UTF-8">
334
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
335
- <title>sign-in timed out · /vibe</title>
336
- <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
337
- <style>
338
- *{margin:0;padding:0;box-sizing:border-box}
339
- body{background:var(--bg,#0A0A0A);color:var(--dim,#9CA3AF);font-family:var(--mono,ui-monospace,Menlo,monospace);
340
- font-size:var(--t-14,14px);line-height:1.6;min-height:100vh;display:flex;align-items:center;
341
- justify-content:center;padding:24px}
342
- .card{border:1px solid var(--line,#1F2937);border-radius:var(--r-lg,10px);background:var(--panel,#111316);
343
- padding:32px;max-width:420px;width:100%}
344
- .logo{color:var(--ink,#E0E0E0);font-size:var(--t-16,16px);margin-bottom:24px}
345
- .logo span{color:var(--blue,#6B8FFF)}
346
- h1{color:var(--ink,#E0E0E0);font-size:var(--t-20,20px);font-weight:600;margin-bottom:8px}
347
- code{color:var(--ink,#E0E0E0);background:var(--bg,#0A0A0A);border:1px solid var(--line,#1F2937);
348
- border-radius:var(--r-sm,6px);padding:1px 6px}
349
- .next{margin-top:24px;padding-top:16px;border-top:1px solid var(--line,#1F2937);font-size:var(--t-13,13px)}
350
- </style></head>
351
- <body><div class="card">
352
- <div class="logo">/<span>vibe</span></div>
353
- <h1>sign-in took too long</h1>
354
- <p>your terminal stopped waiting, so this sign-in didn't finish. nothing is broken and
355
- nothing was saved.</p>
356
- <div class="next">back in your terminal, say <code>vibe init</code> and it will open a fresh
357
- sign-in. this window can be closed.</div>
358
- </div></body></html>`);
359
- return;
360
- }
361
-
362
- // Handle callback
363
- if (url.pathname === '/callback') {
364
- const token = url.searchParams.get('token');
365
- const callbackHandle = url.searchParams.get('handle');
366
-
367
- if (token && callbackHandle) {
368
- // WHO AUTHENTICATED WINS — not who we asked for.
369
- //
370
- // This was `requestedHandle || callbackHandle`, i.e. the handle passed into
371
- // vibe_init overrode the identity the OAuth flow actually returned. Ask to
372
- // sign in as @a while the browser is signed in as @b and the client stores
373
- // @b's token under @a's name: the banner says @a, every message goes out as
374
- // @b. That is exactly the mismatch issue #107 records, reproduced here.
375
- //
376
- // A requested handle is a preference for what to CALL a brand-new account,
377
- // never a claim about whose credential this is.
378
- const finalHandle = callbackHandle;
379
- if (requestedHandle && requestedHandle !== callbackHandle) {
380
- console.error(
381
- `[vibe_init] Signed in as @${callbackHandle} (you asked for @${requestedHandle}) — using the account that authenticated.`
382
- );
383
- }
384
-
385
- // Save to config (file persistence for restarts)
386
- config.saveAuthToken(token);
387
- config.setSessionIdentity(finalHandle, one_liner || '');
388
-
389
- // PUSH to in-memory auth store (immediate propagation)
390
- authStore.setToken(token);
391
- authStore.setHandle(finalHandle);
392
- authStore.setOneLiner(one_liner || '');
393
-
394
- // Update shared config
395
- const cfg = config.load();
396
- cfg.handle = finalHandle;
397
- cfg.one_liner = one_liner || '';
398
- cfg.authMethod = 'browser';
399
- cfg.pendingAuth = false;
400
- config.save(cfg);
401
-
402
- // Register session with API
403
- const sessionId = config.getSessionId();
404
- await store.registerSession(sessionId, finalHandle, one_liner);
405
-
406
- // Send initial heartbeat
407
- await store.heartbeat(finalHandle, one_liner);
408
-
409
- // Post to Discord
410
- discord.postJoin(finalHandle, one_liner);
411
-
412
- // NOTE: Welcome message is sent in the main return path (awaited)
413
- // to ensure it arrives before we show the unread count
414
-
415
- // Send success response to browser - lightweight, no infinite animations
416
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
417
- res.end(`<!DOCTYPE html>
418
- <html lang="en">
419
- <head>
420
- <meta charset="UTF-8">
421
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
422
- <title>signed in to /vibe</title>
423
- <!-- ROOM TONE, from the one source of record. This page is the LAST thing an
424
- invitee sees before the product, so it must not look like a different one:
425
- it used neon #00FF88, cyan, glow shadows, CRT scanlines and two arcade fonts
426
- — every item on the "not this" list. The user just completed a browser OAuth,
427
- so they are online by definition and the hosted token file is safe to link. -->
428
- <link rel="stylesheet" href="https://www.slashvibe.dev/vibe-tokens.css">
429
- <style>
430
- * { margin: 0; padding: 0; box-sizing: border-box; }
431
- body {
432
- background: var(--bg, #0A0A0A);
433
- color: var(--dim, #9CA3AF);
434
- font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
435
- font-size: var(--t-14, 14px);
436
- line-height: 1.6;
437
- min-height: 100vh;
438
- display: flex; align-items: center; justify-content: center;
439
- padding: var(--s-6, 24px);
440
- }
441
- .card {
442
- border: 1px solid var(--line, #1F2937);
443
- border-radius: var(--r-lg, 10px);
444
- background: var(--panel, #111316);
445
- padding: var(--s-8, 32px);
446
- max-width: 420px; width: 100%;
447
- }
448
- .logo { color: var(--ink, #E0E0E0); font-size: var(--t-16, 16px); margin-bottom: var(--s-6, 24px); }
449
- .logo span { color: var(--blue, #6B8FFF); }
450
- .line { color: var(--ink, #E0E0E0); font-size: var(--t-20, 20px); margin-bottom: var(--s-2, 8px); }
451
- /* green is presence and nothing else — here it states a fact that is now true:
452
- this handle is on the board. */
453
- .dot { color: var(--green, #22c55e); }
454
- .meta { font-size: var(--t-13, 13px); }
455
- .next {
456
- margin-top: var(--s-6, 24px); padding-top: var(--s-4, 16px);
457
- border-top: 1px solid var(--line, #1F2937); font-size: var(--t-13, 13px);
458
- }
459
- code {
460
- color: var(--ink, #E0E0E0);
461
- background: var(--bg, #0A0A0A);
462
- border: 1px solid var(--line, #1F2937);
463
- border-radius: var(--r-sm, 6px);
464
- padding: 1px 6px;
465
- }
466
- .close { color: var(--faint, #6B7280); font-size: var(--t-11, 11px); margin-top: var(--s-6, 24px); }
467
- </style>
468
- </head>
469
- <body>
470
- <div class="card">
471
- <div class="logo">/<span>vibe</span></div>
472
- <p class="line"><span class="dot">&#x1F7E2;</span> you're in, @${finalHandle}</p>
473
- <p class="meta">your session is signed in and you're on the board.</p>
474
- <div class="next">
475
- back in your terminal, say <code>vibe who</code> to see who's around,
476
- or <code>vibe inbox</code> if someone already wrote to you.
477
- </div>
478
- <p class="close">you can close this window.</p>
479
- </div>
480
- </body>
481
- </html>`);
482
-
483
- // Close server and resolve
484
- resolved = true;
485
- setTimeout(() => server.close(), 500);
486
- resolve({ success: true, handle: finalHandle });
487
- } else {
488
- res.writeHead(400, { 'Content-Type': 'text/plain' });
489
- res.end('Missing token or handle');
490
- }
491
- } else {
492
- res.writeHead(404);
493
- res.end('Not found');
494
- }
495
- });
496
-
497
- server.on('error', (err) => {
498
- if (err.code === 'EADDRINUSE') {
499
- reject(new Error('AUTH_IN_PROGRESS'));
500
- } else {
501
- reject(err);
502
- }
503
- });
504
-
505
- // Start server
506
- server.listen(CALLBACK_PORT, '127.0.0.1', () => {
507
- console.log(`[vibe_init] Callback server listening on port ${CALLBACK_PORT}`);
508
- });
509
-
510
- // Stop WAITING at the timeout, but keep LISTENING for a grace period.
511
- //
512
- // These were the same moment before, and it produced the worst screen in the
513
- // product: sign-in takes longer than the fuse (first-time GitHub, 2FA, a
514
- // password manager, switching accounts — all normal), the listener closes, and
515
- // the browser lands on a raw ERR_CONNECTION_REFUSED holding a valid token, with
516
- // no explanation and nothing to click. It happened twice in one evening to
517
- // people who built this. An invitee reads that as "broken". (issue #108)
518
- //
519
- // So: the promise rejects on time (the caller must not hang), and the socket
520
- // stays up long enough to answer a late callback with a page that says what
521
- // happened and how to retry.
522
- setTimeout(() => {
523
- if (!resolved) {
524
- timedOut = true;
525
- reject(new Error('AUTH_TIMEOUT'));
526
- setTimeout(() => { if (!resolved) server.close(); }, LATE_CALLBACK_GRACE_MS);
527
- }
528
- }, AUTH_TIMEOUT_MS);
529
- });
530
- }
531
-
532
304
  async function handler(args) {
533
305
  const { handle, one_liner, auth_method } = args;
534
306
 
@@ -621,6 +393,8 @@ To check messages: \`vibe inbox\`${emailNudge}`
621
393
  // BROWSER AUTH (Default): GitHub OAuth
622
394
  // ===========================================
623
395
  if (auth_method === 'browser' || !auth_method) {
396
+ let oauth;
397
+
624
398
  // Save one_liner for callback handler
625
399
  const cfg = config.load();
626
400
  if (h) cfg.handle = h;
@@ -628,18 +402,44 @@ To check messages: \`vibe inbox\`${emailNudge}`
628
402
  cfg.pendingAuth = true;
629
403
  config.save(cfg);
630
404
 
631
- // Build login URL with redirect to our local callback
632
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
633
- const loginUrl = h
634
- ? `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}&handle=${encodeURIComponent(h)}`
635
- : `${LOGIN_URL}?redirect=${encodeURIComponent(callbackUrl)}`;
636
-
637
- // Open browser BEFORE starting to wait
638
- openBrowser(loginUrl);
639
-
640
405
  try {
406
+ // The callback listener is bound before this resolves. Only then is it
407
+ // safe to hand the attempt-correlated URL to the browser.
408
+ oauth = await beginOAuth({ requestedHandle: h });
409
+ openBrowser(oauth.loginUrl);
410
+
641
411
  // Wait for callback (blocks until auth completes or times out)
642
- const result = await waitForCallback(h, one_liner);
412
+ const { token, handle: callbackHandle } = await oauth.waitForCallback();
413
+ const finalHandle = callbackHandle;
414
+
415
+ // Save to config (file persistence for restarts)
416
+ config.saveAuthToken(token);
417
+ config.setSessionIdentity(finalHandle, one_liner || '');
418
+
419
+ // PUSH to in-memory auth store (immediate propagation)
420
+ authStore.setToken(token);
421
+ authStore.setHandle(finalHandle);
422
+ authStore.setOneLiner(one_liner || '');
423
+
424
+ // Update shared config
425
+ const authConfig = config.load();
426
+ authConfig.handle = finalHandle;
427
+ authConfig.one_liner = one_liner || '';
428
+ authConfig.authMethod = 'browser';
429
+ authConfig.pendingAuth = false;
430
+ config.save(authConfig);
431
+
432
+ // Register session with API
433
+ const sessionId = config.getSessionId();
434
+ await store.registerSession(sessionId, finalHandle, one_liner);
435
+
436
+ // Send initial heartbeat
437
+ await store.heartbeat(finalHandle, one_liner);
438
+
439
+ // Post to Discord
440
+ discord.postJoin(finalHandle, one_liner);
441
+
442
+ const result = { success: true, handle: finalHandle };
643
443
 
644
444
  // Send personalized welcome and wait for it (2.5s timeout)
645
445
  let welcomeResult = null;
@@ -747,16 +547,8 @@ To check messages: \`vibe inbox\`${emailNudge}`
747
547
  return {
748
548
  display: `## A login is already running
749
549
 
750
- You've got a sign-in waiting in your browser. Either:
751
- 1. **Finish it** in the browser tab that opened, or
752
- 2. **Open it yourself** if the tab didn't pop up:
753
- ${MANUAL_LOGIN_URL}
754
-
755
- Still stuck? Clear it and start over:
756
- \`\`\`
757
- lsof -ti:9876 | xargs kill
758
- \`\`\`
759
- Then say **"let's vibe"** again.`
550
+ Another sign-in owns the callback listener. Finish that browser sign-in, or say
551
+ **"let's vibe"** again to start a fresh attempt.`
760
552
  };
761
553
  }
762
554
 
@@ -764,26 +556,24 @@ Then say **"let's vibe"** again.`
764
556
  return {
765
557
  display: `## The sign-in timed out
766
558
 
767
- The browser login wasn't finished within 2 minutes — no problem, just start it again.
559
+ The browser login wasn't finished within 5 minutes — no problem, just start it again.
768
560
 
769
561
  **1. Say "let's vibe"** to reopen the login.
770
- **2. If the browser tab doesn't pop up,** click this link yourself:
771
- ${MANUAL_LOGIN_URL}
772
- **3. Sign in with GitHub** in that tab, then come back here.
562
+ **2. Sign in with GitHub** in that tab, then come back here.
773
563
 
774
564
  _Tip: keep this window and the browser both visible so you can see when it finishes._`
775
565
  };
776
566
  }
777
567
 
568
+ if (oauth) await oauth.cancel();
778
569
  return {
779
570
  display: `## Couldn't finish sign-in
780
571
 
781
572
  **What happened:** ${err.message}
782
573
 
783
574
  **Try this:**
784
- 1. **Click the login link directly:** ${MANUAL_LOGIN_URL}
575
+ 1. Say **"let's vibe"** to open a fresh sign-in
785
576
  2. Finish the GitHub sign-in in that tab, then come back
786
- 3. Or say **"let's vibe"** to try the whole thing again
787
577
 
788
578
  **Still stuck?** Email seth@slashvibe.dev — happy to get you in.`
789
579
  };
package/tools/start.js CHANGED
@@ -368,8 +368,7 @@ async function handler(args) {
368
368
  display += `\n\n**🎤 ${guestMessages.length} guest message${guestMessages.length > 1 ? 's' : ''} in your session:**`;
369
369
  guestMessages.forEach(m => {
370
370
  const time = new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
371
- const preview = m.message.length > 80 ? m.message.slice(0, 80) + '...' : m.message;
372
- display += `\n• [${time}] @${m.from}: ${preview}`;
371
+ display += `\n• [${time}] @${inertField(m.from, 40)}: ${inertField(m.message, 80)}`;
373
372
  });
374
373
  display += `\n_Use vibe_guest with action "ack" to clear after reading._`;
375
374
  }
package/tools/token.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  const config = require('../config');
12
12
  const store = require('../store');
13
+ const authStore = require('../auth-store');
13
14
 
14
15
  const definition = {
15
16
  name: 'vibe_token',
@@ -59,8 +60,16 @@ The token may be expired or invalid. Try authenticating again:
59
60
  };
60
61
  }
61
62
 
62
- // Save token
63
+ // Save token — to BOTH authorities, in one step.
64
+ //
65
+ // This wrote config and session identity but never touched the in-memory store,
66
+ // which is what every outbound Authorization header actually reads. The result:
67
+ // display and routing moved to the new account while requests kept going out as
68
+ // the old one (codex #3). The store is updated first, marked verified because the
69
+ // server just confirmed it above.
63
70
  config.saveAuthToken(token.trim());
71
+ authStore.setToken(token.trim(), { verified: true });
72
+ authStore.markVerified(verification.handle);
64
73
 
65
74
  // Update session identity with verified handle
66
75
  const handle = verification.handle;
package/tools/weave.js CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  const config = require('../config');
22
22
  const store = require('../store');
23
+ const { renderIncoming } = require('../incoming');
23
24
  const { requireInit, normalizeHandle, formatTimeAgo, truncate } = require('./_shared');
24
25
 
25
26
  const definition = {
@@ -183,17 +184,28 @@ async function findHeldHalf(myHandle) {
183
184
  async function weaveMoment(myHandle) {
184
185
  const held = await findHeldHalf(myHandle);
185
186
  if (!held) return '';
186
- const inbound = truncate(held.inbound, 240);
187
- let block =
188
- `\n\n🧵 **@${held.handle} replied and you haven't answered (${held.inboundAgo}):**` +
189
- `\n> ${inbound}` +
190
- `\n\n_Fable can hold your half here draft the reply in your voice from what you were building, ` +
191
- `show it, and send it with one word. Draft it now, then: **send it (y) · edit · let it sit**. ` +
192
- `On yes, deliver with \`vibe_dm @${held.handle}\` (origin: "held_half")._`;
193
- if (held.otherWaiting > 0) {
194
- block += `\n_(+${held.otherWaiting} more thread${held.otherWaiting > 1 ? 's' : ''} waiting on you.)_`;
195
- }
196
- return block;
187
+ // This block renders ANOTHER PERSON'S WORDS into a model's context, and it used to
188
+ // do it as a bare markdown blockquote followed by trusted copy saying "Draft it
189
+ // now" and "deliver with vibe_dm". That is untrusted text sitting adjacent to an
190
+ // instruction to compose and send — the strongest injection shape in this codebase,
191
+ // and it fired from DEFAULT vibe_start, not from an opt-in tool.
192
+ //
193
+ // Two changes, both required:
194
+ // 1. the body goes through the shared envelope (framing BEFORE content, markers
195
+ // neutralized) like every other inbound message;
196
+ // 2. nothing here tells the model to draft or send. Drafting is a thing the USER
197
+ // asks for — `vibe weave` still does it on request. A notification may say
198
+ // someone is waiting; it may not act on what they wrote.
199
+ const block =
200
+ renderIncoming([{ from: held.handle, text: held.inbound }], {
201
+ replyTo: held.handle,
202
+ threadHint: true,
203
+ }) +
204
+ `\n_@${held.handle} is waiting on your reply (${held.inboundAgo}). Say \`vibe weave\` if you want help drafting one._`;
205
+ const more = held.otherWaiting > 0
206
+ ? `\n_(+${held.otherWaiting} more thread${held.otherWaiting > 1 ? 's' : ''} waiting on you.)_`
207
+ : '';
208
+ return block + more;
197
209
  }
198
210
 
199
211
  async function handler(args = {}) {
package/tools/who.js CHANGED
@@ -228,8 +228,7 @@ say hi to **@vibe** (the platform account, always around), or send
228
228
  for (const req of helpRequests.slice(0, 3)) {
229
229
  // urgency is a word, not a traffic light
230
230
  const urgencyWord = req.urgency === 'high' ? 'urgent' : req.urgency === 'low' ? 'whenever' : 'soon';
231
- const shortProblem = req.problem.length > 50 ? req.problem.slice(0, 47) + '...' : req.problem;
232
- display += ` **@${req.handle}** (${urgencyWord}): ${shortProblem}\n`;
231
+ display += ` **@${inertField(req.handle, 40)}** (${urgencyWord}): ${inertField(req.problem, 50)}\n`;
233
232
  }
234
233
  display += `→ \`vibe stuck\` to help or ask\n\n---\n\n`;
235
234
  }
@@ -296,7 +295,7 @@ say hi to **@vibe** (the platform account, always around), or send
296
295
  const tag = isMe ? ' _(you)_' : '';
297
296
  const timeAgo = formatTimeAgo(u.lastSeen);
298
297
 
299
- display += `💤 **@${u.handle}**${tag} — _"${u.awayMessage}"_\n`;
298
+ display += `💤 **@${u.handle}**${tag} — _"${inertField(u.awayMessage)}"_\n`;
300
299
  display += ` _${timeAgo}_\n\n`;
301
300
  });
302
301
 
package/version.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
- "version": "0.8.2",
2
+ "version": "0.8.3",
3
3
  "updated": "2026-07-31",
4
- "changelog": "Identity and first-run honesty. The client could display one handle while acting as another: the shown name came from a config file and the acting identity from a token, and nothing tied them together \u2014 so a DM could go out as the wrong account and still report success. The token is now the identity everywhere, including the sign-in flow, which used to let a requested handle override the account that actually authenticated. Presence no longer announces a connection it has not verified. Sign-in gets five minutes instead of two, and a late callback now gets a page explaining what happened instead of the browser's connection-refused error. First-run copy points at the person who invited you rather than at strangers.",
4
+ "changelog": "Security release, and the one that reaches the door people actually use. A clean install runs `npx slashvibe-mcp`, which had its own separate sign-in code \u2014 so the fixes shipped in 0.8.2 were never on that path. There is now one sign-in implementation: the listener binds before the browser opens, each attempt is tagged so another process's login can never land in yours, two sign-ins at once both work, and a slow sign-in gets a page explaining itself instead of a browser error. Identity is one thing everywhere \u2014 pasting a token no longer leaves your requests going out as the previous account, and presence announces the account your credential names rather than one read from a file. A message someone sends you is never placed next to an instruction telling your agent to reply.",
5
5
  "features": [
6
- "The token is the identity \u2014 a stored handle can never override the credential",
7
- "Sign-in keeps the account that authenticated, not the one that was requested",
8
- "Presence announces a session only after it is verified",
9
- "Sign-in window 2 -> 5 minutes, with an honest page if it still expires",
10
- "First run points at your inviter; stranger suggestions move behind VIBE_EXTRAS"
6
+ "One sign-in path for terminal setup and vibe_init \u2014 the invite path has the fixes",
7
+ "Each sign-in attempt is tagged; a concurrent login cannot land in your session",
8
+ "Two sign-ins at once both work (port falls back instead of colliding)",
9
+ "vibe_token switches the whole session, not half of it",
10
+ "Presence names the account your credential names",
11
+ "Inbound messages are never seated beside an instruction to act on them"
11
12
  ],
12
13
  "deprecated": [],
13
14
  "breaking": false,