slashvibe-mcp 0.8.13 → 0.8.15

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/README.md CHANGED
@@ -18,6 +18,23 @@ GitHub to sign you in, and your GitHub username becomes your @handle. About 30 s
18
18
  Invited by someone? After setup, say `vibe inbox` — their message is waiting. Reply and
19
19
  you're done; that's the whole onboarding.
20
20
 
21
+ ## Put waiting messages at the top of your next Claude session
22
+
23
+ For the small, opt-in Gate 1 pilot, install the read-only Claude Code `SessionStart` hook:
24
+
25
+ ```bash
26
+ npx slashvibe-mcp hook install
27
+ ```
28
+
29
+ The hook checks the ordinary inbox when Claude starts or resumes. It writes no read state,
30
+ delivery claim or receipt, so a waiting message may honestly appear again on another
31
+ startup during the pilot. Check or reverse the installation at any time:
32
+
33
+ ```bash
34
+ npx slashvibe-mcp hook status
35
+ npx slashvibe-mcp hook uninstall
36
+ ```
37
+
21
38
  ## Manual setup
22
39
 
23
40
  If you'd rather wire it yourself, add to `~/.claude.json` (or your host's MCP config):
@@ -43,8 +60,8 @@ Then run `vibe init` in your session to sign in.
43
60
  The default surface is deliberately small — 10 tools:
44
61
 
45
62
  - **who** — who's here now (🟢), who's idle, which agents are around
46
- - **dm / inbox / reply** — messages that survive restarts on both sides. A reply finds
47
- your current session, or the top of your next one, exactly once
63
+ - **dm / inbox / reply** — messages that survive restarts on both sides. The optional
64
+ read-only Claude hook can put a waiting reply at the top of your next session
48
65
  - **status** — what you're working on, in words (`shipping`, `debugging`)
49
66
  - **help**, plus setup plumbing (`init`, `token`, `bye`, `email`)
50
67
 
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const ACTOR_BUNDLE_VERSION = 1;
9
+ const ACTOR_SESSION_FILE = 'actor-session.json';
10
+ const ACTOR_LOCK_DIRECTORY = '.actor-session.lock';
11
+ const ACTOR_LOCK_OWNER = 'owner.json';
12
+ const REFRESH_TOKEN_PATTERN = /^vrt_[A-Za-z0-9_-]{43}$/;
13
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
14
+ const DEFAULT_LOCK_WAIT_MS = 5000;
15
+ const DEFAULT_LOCK_STALE_MS = 15000;
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
17
+ const DEFAULT_MINIMUM_TTL_SECONDS = 30;
18
+
19
+ class ActorSessionError extends Error {
20
+ constructor(code, options = {}) {
21
+ super(options.message || code);
22
+ this.name = 'ActorSessionError';
23
+ this.code = code;
24
+ this.reason = options.reason || null;
25
+ this.reauthenticate = options.reauthenticate === true;
26
+ this.ambiguous = options.ambiguous === true;
27
+ if (options.cause) this.cause = options.cause;
28
+ }
29
+ }
30
+
31
+ function actorPaths(env = process.env) {
32
+ const home = env.HOME || os.homedir();
33
+ const configured = env.VIBE_HOME
34
+ ? String(env.VIBE_HOME).replace(/^~(?=$|\/)/, home)
35
+ : path.join(home, '.vibe');
36
+ const directory = path.resolve(configured);
37
+ return {
38
+ directory,
39
+ sessionFile: path.join(directory, ACTOR_SESSION_FILE),
40
+ lockDirectory: path.join(directory, ACTOR_LOCK_DIRECTORY),
41
+ lockOwner: path.join(directory, ACTOR_LOCK_DIRECTORY, ACTOR_LOCK_OWNER),
42
+ };
43
+ }
44
+
45
+ function parseAccessToken(token) {
46
+ try {
47
+ const parts = String(token || '').split('.');
48
+ if (parts.length !== 3 || parts.some((part) => !part)) return null;
49
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
50
+ if (
51
+ typeof payload.sub !== 'string' ||
52
+ !UUID_PATTERN.test(payload.sub) ||
53
+ typeof payload.sid !== 'string' ||
54
+ !UUID_PATTERN.test(payload.sid) ||
55
+ typeof payload.exp !== 'number' ||
56
+ !Number.isFinite(payload.exp)
57
+ ) {
58
+ return null;
59
+ }
60
+ return {
61
+ principalId: payload.sub,
62
+ runtimeId: payload.sid,
63
+ expiresAt: payload.exp,
64
+ };
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function normalizedBundle(input, timestamp) {
71
+ if (!input || typeof input !== 'object') return null;
72
+ const refreshToken = input.refreshToken;
73
+ const principalId = input.principalId;
74
+ const runtimeId = input.runtimeId;
75
+ const handleVersion = input.handleVersion;
76
+ if (
77
+ typeof refreshToken !== 'string' ||
78
+ !REFRESH_TOKEN_PATTERN.test(refreshToken) ||
79
+ typeof principalId !== 'string' ||
80
+ !UUID_PATTERN.test(principalId) ||
81
+ typeof runtimeId !== 'string' ||
82
+ !UUID_PATTERN.test(runtimeId) ||
83
+ typeof handleVersion !== 'string' ||
84
+ !UUID_PATTERN.test(handleVersion)
85
+ ) {
86
+ return null;
87
+ }
88
+ return {
89
+ version: ACTOR_BUNDLE_VERSION,
90
+ refreshToken,
91
+ principalId,
92
+ runtimeId,
93
+ handleVersion,
94
+ updatedAt: new Date(timestamp).toISOString(),
95
+ };
96
+ }
97
+
98
+ function createActorSessionManager(options = {}) {
99
+ const env = options.env || process.env;
100
+ const paths = options.paths || actorPaths(env);
101
+ const fileSystem = options.fs || fs;
102
+ const now = options.now || (() => Date.now());
103
+ const randomId = options.randomUUID || crypto.randomUUID;
104
+ const wait =
105
+ options.wait || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
106
+ const fetchImpl = options.fetch || globalThis.fetch;
107
+ const apiUrl = String(options.apiUrl || env.VIBE_API_URL || 'https://www.slashvibe.dev').replace(
108
+ /\/$/,
109
+ ''
110
+ );
111
+ const lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
112
+ const lockStaleMs = options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
113
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
114
+ let accessCache = null;
115
+ let refreshInFlight = null;
116
+
117
+ function ensureDirectory() {
118
+ fileSystem.mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
119
+ fileSystem.chmodSync(paths.directory, 0o700);
120
+ }
121
+
122
+ function atomicWriteBundle(bundle) {
123
+ ensureDirectory();
124
+ const temporary = path.join(
125
+ paths.directory,
126
+ `.${ACTOR_SESSION_FILE}.${process.pid}.${randomId()}.tmp`
127
+ );
128
+ let descriptor = null;
129
+ try {
130
+ descriptor = fileSystem.openSync(temporary, 'wx', 0o600);
131
+ fileSystem.writeFileSync(descriptor, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
132
+ fileSystem.fsyncSync(descriptor);
133
+ fileSystem.closeSync(descriptor);
134
+ descriptor = null;
135
+ fileSystem.chmodSync(temporary, 0o600);
136
+ fileSystem.renameSync(temporary, paths.sessionFile);
137
+ fileSystem.chmodSync(paths.sessionFile, 0o600);
138
+ } catch (error) {
139
+ if (descriptor !== null) {
140
+ try {
141
+ fileSystem.closeSync(descriptor);
142
+ } catch {}
143
+ }
144
+ try {
145
+ fileSystem.unlinkSync(temporary);
146
+ } catch {}
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ function clearUnsafe() {
152
+ accessCache = null;
153
+ try {
154
+ fileSystem.unlinkSync(paths.sessionFile);
155
+ } catch (error) {
156
+ if (error?.code !== 'ENOENT') throw error;
157
+ }
158
+ }
159
+
160
+ function readBundle() {
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(fileSystem.readFileSync(paths.sessionFile, 'utf8'));
164
+ } catch (error) {
165
+ if (error?.code === 'ENOENT') return null;
166
+ clearUnsafe();
167
+ throw new ActorSessionError('actor_reauthentication_required', {
168
+ reason: 'actor_bundle_invalid',
169
+ reauthenticate: true,
170
+ cause: error,
171
+ });
172
+ }
173
+ const bundle = normalizedBundle(parsed, now());
174
+ if (!bundle || parsed.version !== ACTOR_BUNDLE_VERSION) {
175
+ clearUnsafe();
176
+ throw new ActorSessionError('actor_reauthentication_required', {
177
+ reason: 'actor_bundle_invalid',
178
+ reauthenticate: true,
179
+ });
180
+ }
181
+ bundle.updatedAt = parsed.updatedAt;
182
+ return bundle;
183
+ }
184
+
185
+ function processIsAlive(pid) {
186
+ if (!Number.isInteger(pid) || pid <= 0) return false;
187
+ try {
188
+ process.kill(pid, 0);
189
+ return true;
190
+ } catch (error) {
191
+ return error?.code === 'EPERM';
192
+ }
193
+ }
194
+
195
+ function removeStaleLock() {
196
+ let owner = null;
197
+ let age = 0;
198
+ try {
199
+ owner = JSON.parse(fileSystem.readFileSync(paths.lockOwner, 'utf8'));
200
+ } catch {}
201
+ try {
202
+ age = now() - fileSystem.statSync(paths.lockDirectory).mtimeMs;
203
+ } catch {
204
+ return false;
205
+ }
206
+ if (age <= lockStaleMs || (owner?.pid && processIsAlive(Number(owner.pid)))) return false;
207
+ try {
208
+ fileSystem.unlinkSync(paths.lockOwner);
209
+ } catch (error) {
210
+ if (error?.code !== 'ENOENT') return false;
211
+ }
212
+ try {
213
+ fileSystem.rmdirSync(paths.lockDirectory);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ async function acquireLock() {
221
+ ensureDirectory();
222
+ const deadline = now() + lockWaitMs;
223
+ const nonce = randomId();
224
+ for (;;) {
225
+ let madeLockDirectory = false;
226
+ try {
227
+ fileSystem.mkdirSync(paths.lockDirectory, { mode: 0o700 });
228
+ madeLockDirectory = true;
229
+ fileSystem.writeFileSync(
230
+ paths.lockOwner,
231
+ `${JSON.stringify({ pid: process.pid, nonce, acquiredAt: new Date(now()).toISOString() })}\n`,
232
+ { encoding: 'utf8', flag: 'wx', mode: 0o600 }
233
+ );
234
+ return { nonce };
235
+ } catch (error) {
236
+ if (madeLockDirectory) {
237
+ try {
238
+ fileSystem.unlinkSync(paths.lockOwner);
239
+ } catch {}
240
+ try {
241
+ fileSystem.rmdirSync(paths.lockDirectory);
242
+ } catch {}
243
+ }
244
+ if (error?.code !== 'EEXIST') throw error;
245
+ if (removeStaleLock()) continue;
246
+ if (now() >= deadline) {
247
+ throw new ActorSessionError('actor_refresh_busy', { reason: 'lock_timeout' });
248
+ }
249
+ await wait(25);
250
+ }
251
+ }
252
+ }
253
+
254
+ function releaseLock(lock) {
255
+ let owner = null;
256
+ try {
257
+ owner = JSON.parse(fileSystem.readFileSync(paths.lockOwner, 'utf8'));
258
+ } catch {}
259
+ if (owner?.nonce !== lock.nonce) return;
260
+ try {
261
+ fileSystem.unlinkSync(paths.lockOwner);
262
+ } catch {}
263
+ try {
264
+ fileSystem.rmdirSync(paths.lockDirectory);
265
+ } catch {}
266
+ }
267
+
268
+ async function withLock(operation) {
269
+ const lock = await acquireLock();
270
+ try {
271
+ return await operation();
272
+ } finally {
273
+ releaseLock(lock);
274
+ }
275
+ }
276
+
277
+ function cacheAccessToken(token, bundle) {
278
+ const claims = parseAccessToken(token);
279
+ if (
280
+ !claims ||
281
+ claims.principalId !== bundle.principalId ||
282
+ claims.runtimeId !== bundle.runtimeId ||
283
+ claims.expiresAt * 1000 <= now()
284
+ ) {
285
+ throw new ActorSessionError('actor_access_token_invalid', {
286
+ reason: 'access_binding_invalid',
287
+ });
288
+ }
289
+ accessCache = { token, ...claims };
290
+ return token;
291
+ }
292
+
293
+ function reauthenticationError(reason, options = {}) {
294
+ return new ActorSessionError('actor_reauthentication_required', {
295
+ reason,
296
+ reauthenticate: true,
297
+ ambiguous: options.ambiguous === true,
298
+ cause: options.cause,
299
+ });
300
+ }
301
+
302
+ async function rotate(bundle) {
303
+ if (typeof fetchImpl !== 'function') {
304
+ throw new ActorSessionError('actor_refresh_unavailable', { reason: 'fetch_unavailable' });
305
+ }
306
+ const controller = new AbortController();
307
+ const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
308
+ let response;
309
+ try {
310
+ response = await fetchImpl(`${apiUrl}/api/auth/token`, {
311
+ method: 'POST',
312
+ headers: {
313
+ 'Content-Type': 'application/json',
314
+ 'Cache-Control': 'no-store',
315
+ },
316
+ body: JSON.stringify({
317
+ grant_type: 'refresh_token',
318
+ refresh_token: bundle.refreshToken,
319
+ }),
320
+ signal: controller.signal,
321
+ });
322
+ } catch (error) {
323
+ clearUnsafe();
324
+ throw reauthenticationError('refresh_ambiguous', {
325
+ ambiguous: true,
326
+ cause: error,
327
+ });
328
+ } finally {
329
+ clearTimeout(timeout);
330
+ }
331
+
332
+ if (!response || response.status === 401) {
333
+ clearUnsafe();
334
+ throw reauthenticationError('invalid_grant');
335
+ }
336
+ if (!response.ok) {
337
+ clearUnsafe();
338
+ throw reauthenticationError('refresh_ambiguous', { ambiguous: true });
339
+ }
340
+
341
+ let payload;
342
+ try {
343
+ payload = await response.json();
344
+ } catch (error) {
345
+ clearUnsafe();
346
+ throw reauthenticationError('refresh_ambiguous', {
347
+ ambiguous: true,
348
+ cause: error,
349
+ });
350
+ }
351
+
352
+ const nextBundle = normalizedBundle(
353
+ {
354
+ refreshToken: payload?.refresh_token,
355
+ principalId: payload?.principal_id,
356
+ runtimeId: payload?.runtime_id,
357
+ handleVersion: payload?.handle_version,
358
+ },
359
+ now()
360
+ );
361
+ if (
362
+ !nextBundle ||
363
+ payload?.token_type !== 'Bearer' ||
364
+ typeof payload?.access_token !== 'string' ||
365
+ nextBundle.principalId !== bundle.principalId ||
366
+ nextBundle.runtimeId !== bundle.runtimeId
367
+ ) {
368
+ clearUnsafe();
369
+ throw reauthenticationError('refresh_response_invalid', { ambiguous: true });
370
+ }
371
+
372
+ try {
373
+ cacheAccessToken(payload.access_token, nextBundle);
374
+ atomicWriteBundle(nextBundle);
375
+ } catch (error) {
376
+ clearUnsafe();
377
+ if (error instanceof ActorSessionError && error.reauthenticate) throw error;
378
+ throw reauthenticationError('refresh_persistence_failed', {
379
+ ambiguous: true,
380
+ cause: error,
381
+ });
382
+ }
383
+ return accessCache.token;
384
+ }
385
+
386
+ async function installOAuthSession(session) {
387
+ return withLock(async () => {
388
+ const bundle = normalizedBundle(session, now());
389
+ if (!bundle || typeof session?.accessToken !== 'string') {
390
+ throw new ActorSessionError('actor_oauth_bundle_invalid');
391
+ }
392
+ try {
393
+ cacheAccessToken(session.accessToken, bundle);
394
+ atomicWriteBundle(bundle);
395
+ } catch (error) {
396
+ clearUnsafe();
397
+ if (error instanceof ActorSessionError) throw error;
398
+ throw new ActorSessionError('actor_oauth_persistence_failed', { cause: error });
399
+ }
400
+ return {
401
+ principalId: bundle.principalId,
402
+ runtimeId: bundle.runtimeId,
403
+ handleVersion: bundle.handleVersion,
404
+ };
405
+ });
406
+ }
407
+
408
+ async function refreshAccessToken(minimumTtlSeconds = DEFAULT_MINIMUM_TTL_SECONDS) {
409
+ if (refreshInFlight) return refreshInFlight;
410
+ refreshInFlight = withLock(async () => {
411
+ const bundle = readBundle();
412
+ if (!bundle) return null;
413
+ if (
414
+ accessCache &&
415
+ accessCache.principalId === bundle.principalId &&
416
+ accessCache.runtimeId === bundle.runtimeId &&
417
+ accessCache.expiresAt * 1000 > now() + minimumTtlSeconds * 1000
418
+ ) {
419
+ return accessCache.token;
420
+ }
421
+ return rotate(bundle);
422
+ }).finally(() => {
423
+ refreshInFlight = null;
424
+ });
425
+ return refreshInFlight;
426
+ }
427
+
428
+ async function getAccessToken(optionsForCall = {}) {
429
+ const minimumTtlSeconds = optionsForCall.minimumTtlSeconds ?? DEFAULT_MINIMUM_TTL_SECONDS;
430
+ if (accessCache) {
431
+ // The durable bundle is also the cross-process stop signal. Another process
432
+ // may have cleared it after an ambiguous/spent refresh; returning a still-live
433
+ // memory token here would let this process perform a delivery transition after
434
+ // the family entered reauthentication. Atomic replacement makes this read safe.
435
+ const bundle = readBundle();
436
+ if (!bundle) {
437
+ accessCache = null;
438
+ return null;
439
+ }
440
+ if (
441
+ accessCache.principalId === bundle.principalId &&
442
+ accessCache.runtimeId === bundle.runtimeId &&
443
+ accessCache.expiresAt * 1000 > now() + minimumTtlSeconds * 1000
444
+ ) {
445
+ return accessCache.token;
446
+ }
447
+ }
448
+ return refreshAccessToken(minimumTtlSeconds);
449
+ }
450
+
451
+ async function clearActorSession() {
452
+ return withLock(async () => clearUnsafe());
453
+ }
454
+
455
+ return {
456
+ paths,
457
+ clearActorSession,
458
+ getAccessToken,
459
+ installOAuthSession,
460
+ readBundle,
461
+ refreshAccessToken,
462
+ };
463
+ }
464
+
465
+ const actorSession = createActorSessionManager();
466
+
467
+ module.exports = {
468
+ ACTOR_BUNDLE_VERSION,
469
+ ActorSessionError,
470
+ actorPaths,
471
+ createActorSessionManager,
472
+ getAccessToken: actorSession.getAccessToken,
473
+ installOAuthSession: actorSession.installOAuthSession,
474
+ clearActorSession: actorSession.clearActorSession,
475
+ };
package/cli.js CHANGED
@@ -14,7 +14,18 @@
14
14
 
15
15
  const args = process.argv.slice(2);
16
16
 
17
- if (args.includes('setup')) {
17
+ if (args[0] === 'hook') {
18
+ require('./hook-cli')
19
+ .run(args.slice(1))
20
+ .catch((error) => {
21
+ if (args[1] === 'run') {
22
+ process.stdout.write(JSON.stringify({ suppressOutput: true }));
23
+ } else {
24
+ process.stderr.write(`${error?.message || error}\n`);
25
+ process.exitCode = 1;
26
+ }
27
+ });
28
+ } else if (args.includes('setup')) {
18
29
  require('./setup.js');
19
30
  } else if (process.stdin.isTTY) {
20
31
  // Running from terminal (not as MCP server)
package/hook-cli.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const { createHookSettingsManager, HookSettingsError } = require('./session-start-hook-settings');
4
+
5
+ function printStatus(result) {
6
+ if (result.installed) {
7
+ process.stdout.write(
8
+ `/vibe read-only SessionStart resurfacing is installed.\n${result.path}\n`
9
+ );
10
+ } else {
11
+ process.stdout.write(
12
+ `/vibe read-only SessionStart resurfacing is not installed.\n${result.path}\n`
13
+ );
14
+ }
15
+ }
16
+
17
+ async function run(args = []) {
18
+ const action = args[0];
19
+ if (action === 'run') {
20
+ const hook = require('./session-start-hook.cjs');
21
+ await hook.main();
22
+ return;
23
+ }
24
+
25
+ const manager = createHookSettingsManager();
26
+ if (action === 'install') {
27
+ const result = manager.install();
28
+ process.stdout.write(
29
+ result.changed
30
+ ? `/vibe read-only SessionStart resurfacing installed. Restart Claude Code, then use /hooks to verify it.\n`
31
+ : `/vibe read-only SessionStart resurfacing was already installed.\n`
32
+ );
33
+ return;
34
+ }
35
+ if (action === 'uninstall') {
36
+ const result = manager.uninstall();
37
+ process.stdout.write(
38
+ result.changed
39
+ ? `/vibe read-only SessionStart resurfacing removed.\n`
40
+ : `/vibe read-only SessionStart resurfacing was not installed.\n`
41
+ );
42
+ return;
43
+ }
44
+ if (action === 'status') {
45
+ printStatus(manager.status());
46
+ return;
47
+ }
48
+
49
+ throw new HookSettingsError('hook_command_invalid', {
50
+ message: 'Usage: npx slashvibe-mcp hook <install|status|uninstall>',
51
+ });
52
+ }
53
+
54
+ module.exports = { run };
package/index.js CHANGED
@@ -16,6 +16,7 @@ const store = require('./store');
16
16
  const prompts = require('./prompts');
17
17
  const NotificationEmitter = require('./notification-emitter');
18
18
  const authStore = require('./auth-store');
19
+ const actorSession = require('./actor-session');
19
20
  const { apiHeaders } = require('./api-auth');
20
21
  const presenceBoard = require('./resources/presence-board');
21
22
  const { resolveFooter } = require('./footer');
@@ -380,6 +381,20 @@ class VibeMCPServer {
380
381
  // Hydrate auth state from disk FIRST (before any tools need it)
381
382
  authStore.hydrate();
382
383
 
384
+ // An access token is memory/cache; only the rotating refresh bundle survives a
385
+ // process. Refresh once at startup so a dead/reused family becomes one truthful
386
+ // reauthentication state before any future delivery transition can use it.
387
+ this.actorAccessReady = actorSession.getAccessToken().catch((error) => {
388
+ if (error?.reauthenticate) {
389
+ process.stderr.write(
390
+ '/vibe: terminal delivery sign-in needs renewal — say "vibe init"; ordinary /vibe sign-in is unchanged\n'
391
+ );
392
+ } else if (error?.code === 'actor_refresh_busy') {
393
+ process.stderr.write('/vibe: terminal delivery sign-in is being refreshed by another session\n');
394
+ }
395
+ return null;
396
+ });
397
+
383
398
  // Initialize notification emitter
384
399
  this.notifier = new NotificationEmitter(this);
385
400
 
@@ -599,6 +614,9 @@ class VibeMCPServer {
599
614
  });
600
615
  }
601
616
 
617
+ // The HTTP store creates one random key per logical send and preserves it
618
+ // across its internal transport retries. JSON-RPC ids restart every MCP
619
+ // process, so deriving a durable key from `id` would collapse future sends.
602
620
  const result = await tool.handler(args);
603
621
 
604
622
  // Emit list_changed notification for state-changing tools
package/oauth-callback.js CHANGED
@@ -8,6 +8,9 @@ const DEFAULT_CALLBACK_PORT = 9876;
8
8
  const DEFAULT_TIMEOUT_MS = 300000;
9
9
  const DEFAULT_GRACE_MS = 300000;
10
10
  const LOGIN_URL = 'https://www.slashvibe.dev/login';
11
+ const ACTOR_BODY_LIMIT = 8192;
12
+ const ACTOR_REFRESH_PATTERN = /^vrt_[A-Za-z0-9_-]{43}$/;
13
+ const ACTOR_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
14
 
12
15
  const SUCCESS_PAGE = `<!DOCTYPE html>
13
16
  <html lang="en">
@@ -101,6 +104,71 @@ const BAD_CALLBACK_PAGE = `<!DOCTYPE html>
101
104
  <title>sign-in could not finish · /vibe</title></head>
102
105
  <body><p>this sign-in callback could not be accepted. return to your terminal and try again.</p></body></html>`;
103
106
 
107
+ function actorCapturePage(state) {
108
+ const script = `<script>
109
+ (() => {
110
+ const body = window.location.hash.slice(1) || 'actor_status=unavailable';
111
+ window.history.replaceState(null, '', window.location.pathname);
112
+ fetch('/actor-callback?state=' + encodeURIComponent(${JSON.stringify(state)}), {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
115
+ cache: 'no-store',
116
+ credentials: 'omit',
117
+ body
118
+ }).catch(() => {});
119
+ })();
120
+ </script>`;
121
+ return SUCCESS_PAGE.replace('</body>', `${script}\n</body>`);
122
+ }
123
+
124
+ function readBody(request, limit = ACTOR_BODY_LIMIT) {
125
+ return new Promise((resolve, reject) => {
126
+ let body = '';
127
+ request.setEncoding('utf8');
128
+ request.on('data', (chunk) => {
129
+ body += chunk;
130
+ if (body.length > limit) {
131
+ reject(new Error('ACTOR_CALLBACK_TOO_LARGE'));
132
+ request.destroy();
133
+ }
134
+ });
135
+ request.on('end', () => resolve(body));
136
+ request.on('error', reject);
137
+ });
138
+ }
139
+
140
+ function actorSessionFromBody(body) {
141
+ const fields = new URLSearchParams(body);
142
+ if (fields.get('actor_status') === 'unavailable') {
143
+ const credentialFields = [
144
+ 'actor_access_token',
145
+ 'actor_refresh_token',
146
+ 'principal_id',
147
+ 'runtime_id',
148
+ 'handle_version',
149
+ ];
150
+ return credentialFields.some((field) => fields.has(field)) ? undefined : null;
151
+ }
152
+
153
+ const accessToken = fields.get('actor_access_token');
154
+ const refreshToken = fields.get('actor_refresh_token');
155
+ const principalId = fields.get('principal_id');
156
+ const runtimeId = fields.get('runtime_id');
157
+ const handleVersion = fields.get('handle_version');
158
+ if (
159
+ typeof accessToken !== 'string' ||
160
+ accessToken.length < 16 ||
161
+ accessToken.length > 4096 ||
162
+ !ACTOR_REFRESH_PATTERN.test(refreshToken || '') ||
163
+ !ACTOR_UUID_PATTERN.test(principalId || '') ||
164
+ !ACTOR_UUID_PATTERN.test(runtimeId || '') ||
165
+ !ACTOR_UUID_PATTERN.test(handleVersion || '')
166
+ ) {
167
+ return undefined;
168
+ }
169
+ return { accessToken, refreshToken, principalId, runtimeId, handleVersion };
170
+ }
171
+
104
172
  function listen(server, port) {
105
173
  return new Promise((resolve, reject) => {
106
174
  const onError = (error) => {
@@ -125,6 +193,7 @@ function listen(server, port) {
125
193
  */
126
194
  async function beginOAuth({
127
195
  requestedHandle,
196
+ actorAware = false,
128
197
  timeoutMs = DEFAULT_TIMEOUT_MS,
129
198
  graceMs = DEFAULT_GRACE_MS,
130
199
  } = {}) {
@@ -135,6 +204,7 @@ async function beginOAuth({
135
204
  let resolveCallback;
136
205
  let rejectCallback;
137
206
  let closePromise;
207
+ let legacyResult = null;
138
208
 
139
209
  const callbackPromise = new Promise((resolve, reject) => {
140
210
  resolveCallback = resolve;
@@ -145,9 +215,18 @@ async function beginOAuth({
145
215
  callbackPromise.catch(() => {});
146
216
 
147
217
  const server = http.createServer((req, res) => {
218
+ handleRequest(req, res).catch(() => {
219
+ if (!res.headersSent) {
220
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
221
+ }
222
+ if (!res.writableEnded) res.end(BAD_CALLBACK_PAGE);
223
+ });
224
+ });
225
+
226
+ async function handleRequest(req, res) {
148
227
  const url = new URL(req.url, `http://${CALLBACK_HOST}`);
149
228
 
150
- if (url.pathname !== '/callback') {
229
+ if (url.pathname !== '/callback' && url.pathname !== '/actor-callback') {
151
230
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
152
231
  res.end('Not found');
153
232
  return;
@@ -165,6 +244,46 @@ async function beginOAuth({
165
244
  return;
166
245
  }
167
246
 
247
+ if (url.pathname === '/actor-callback') {
248
+ if (!actorAware || req.method !== 'POST' || phase !== 'waitingActor' || !legacyResult) {
249
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
250
+ res.end(BAD_CALLBACK_PAGE);
251
+ return;
252
+ }
253
+ let actorSession;
254
+ try {
255
+ actorSession = actorSessionFromBody(await readBody(req));
256
+ } catch {
257
+ actorSession = undefined;
258
+ }
259
+ if (actorSession === undefined) {
260
+ phase = 'completed';
261
+ clearTimeout(timeoutTimer);
262
+ clearTimeout(graceTimer);
263
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
264
+ res.end(BAD_CALLBACK_PAGE, () => closeServer().catch(() => {}));
265
+ rejectCallback(new Error('ACTOR_CALLBACK_INVALID'));
266
+ return;
267
+ }
268
+
269
+ phase = 'completed';
270
+ clearTimeout(timeoutTimer);
271
+ clearTimeout(graceTimer);
272
+ res.writeHead(204, {
273
+ 'Cache-Control': 'no-store',
274
+ Pragma: 'no-cache',
275
+ });
276
+ res.end('', () => closeServer().catch(() => {}));
277
+ resolveCallback({ ...legacyResult, actor: actorSession });
278
+ return;
279
+ }
280
+
281
+ if (req.method !== 'GET') {
282
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
283
+ res.end('Method not allowed');
284
+ return;
285
+ }
286
+
168
287
  const token = url.searchParams.get('token');
169
288
  const callbackHandle = url.searchParams.get('handle');
170
289
  if (phase !== 'waiting' || !token || !callbackHandle) {
@@ -181,13 +300,25 @@ async function beginOAuth({
181
300
  );
182
301
  }
183
302
 
303
+ legacyResult = { token, handle: callbackHandle };
304
+ if (actorAware) {
305
+ phase = 'waitingActor';
306
+ res.writeHead(200, {
307
+ 'Content-Type': 'text/html; charset=utf-8',
308
+ 'Cache-Control': 'no-store',
309
+ Pragma: 'no-cache',
310
+ });
311
+ res.end(actorCapturePage(state));
312
+ return;
313
+ }
314
+
184
315
  phase = 'completed';
185
316
  clearTimeout(timeoutTimer);
186
317
  clearTimeout(graceTimer);
187
318
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
188
319
  res.end(SUCCESS_PAGE, () => { closeServer().catch(() => {}); });
189
- resolveCallback({ token, handle: callbackHandle });
190
- });
320
+ resolveCallback(legacyResult);
321
+ }
191
322
 
192
323
  function closeServer() {
193
324
  if (closePromise) return closePromise;
@@ -233,9 +364,10 @@ async function beginOAuth({
233
364
  loginUrl.searchParams.set('redirect', callbackUrl.toString());
234
365
  loginUrl.searchParams.set('state', state);
235
366
  if (requestedHandle) loginUrl.searchParams.set('handle', requestedHandle);
367
+ if (actorAware) loginUrl.searchParams.set('actor_aware', 'true');
236
368
 
237
369
  timeoutTimer = setTimeout(() => {
238
- if (phase !== 'waiting') return;
370
+ if (phase !== 'waiting' && phase !== 'waitingActor') return;
239
371
  phase = 'timedOut';
240
372
  rejectCallback(new Error('AUTH_TIMEOUT'));
241
373
  graceTimer = setTimeout(() => {
@@ -249,7 +381,7 @@ async function beginOAuth({
249
381
  async function cancel() {
250
382
  clearTimeout(timeoutTimer);
251
383
  clearTimeout(graceTimer);
252
- if (phase === 'waiting') {
384
+ if (phase === 'waiting' || phase === 'waitingActor') {
253
385
  phase = 'cancelled';
254
386
  rejectCallback(new Error('AUTH_CANCELLED'));
255
387
  } else if (phase !== 'closed') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
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",
@@ -49,6 +49,7 @@
49
49
  "files": [
50
50
  "README.md",
51
51
  "api-auth.js",
52
+ "actor-session.js",
52
53
  "auth-store.js",
53
54
  "auto-update.js",
54
55
  "cli.js",
@@ -57,6 +58,7 @@
57
58
  "discord.js",
58
59
  "footer.js",
59
60
  "games/chess.js",
61
+ "hook-cli.js",
60
62
  "host.js",
61
63
  "incoming.js",
62
64
  "index.js",
@@ -74,6 +76,8 @@
74
76
  "resources/presence-board.js",
75
77
  "resources/vibe-tokens.json",
76
78
  "setup.js",
79
+ "session-start-hook.cjs",
80
+ "session-start-hook-settings.js",
77
81
  "store/api.js",
78
82
  "store/index.js",
79
83
  "store/local.js",
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const packageJson = require('./package.json');
8
+
9
+ const HOOK_MATCHER = 'startup|resume';
10
+ const HOOK_TIMEOUT_SECONDS = 6;
11
+ const VERSIONED_HOOK_COMMAND =
12
+ /^npx -y slashvibe-mcp@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)? hook run$/;
13
+
14
+ class HookSettingsError extends Error {
15
+ constructor(code, options = {}) {
16
+ super(options.message || code);
17
+ this.name = 'HookSettingsError';
18
+ this.code = code;
19
+ if (options.cause) this.cause = options.cause;
20
+ }
21
+ }
22
+
23
+ function hookCommand(version = packageJson.version) {
24
+ return `npx -y slashvibe-mcp@${version} hook run`;
25
+ }
26
+
27
+ function settingsPath(env = process.env) {
28
+ const home = env.HOME || os.homedir();
29
+ const claudeDirectory = env.CLAUDE_CONFIG_DIR
30
+ ? path.resolve(String(env.CLAUDE_CONFIG_DIR).replace(/^~(?=$|\/)/, home))
31
+ : path.join(home, '.claude');
32
+ return path.join(claudeDirectory, 'settings.json');
33
+ }
34
+
35
+ function readSettings(target, fileSystem = fs) {
36
+ let raw;
37
+ try {
38
+ raw = fileSystem.readFileSync(target, 'utf8');
39
+ } catch (error) {
40
+ if (error?.code === 'ENOENT') return { settings: {}, exists: false, mode: 0o600 };
41
+ throw new HookSettingsError('claude_settings_read_failed', { cause: error });
42
+ }
43
+
44
+ try {
45
+ const settings = JSON.parse(raw);
46
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error();
47
+ const mode = fileSystem.statSync(target).mode & 0o777;
48
+ return { settings, exists: true, mode };
49
+ } catch (error) {
50
+ throw new HookSettingsError('claude_settings_invalid', { cause: error });
51
+ }
52
+ }
53
+
54
+ function validateHookShape(settings) {
55
+ if (
56
+ settings.hooks !== undefined &&
57
+ (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))
58
+ ) {
59
+ throw new HookSettingsError('claude_hooks_invalid');
60
+ }
61
+ if (settings.hooks?.SessionStart !== undefined && !Array.isArray(settings.hooks.SessionStart)) {
62
+ throw new HookSettingsError('claude_session_start_hooks_invalid');
63
+ }
64
+ }
65
+
66
+ function ownedHandler(handler, command = hookCommand()) {
67
+ return (
68
+ handler &&
69
+ typeof handler === 'object' &&
70
+ handler.type === 'command' &&
71
+ (handler.command === command || VERSIONED_HOOK_COMMAND.test(handler.command))
72
+ );
73
+ }
74
+
75
+ function removeOwned(settings, command = hookCommand()) {
76
+ validateHookShape(settings);
77
+ if (!Array.isArray(settings.hooks?.SessionStart)) return { settings, removed: 0 };
78
+
79
+ let removed = 0;
80
+ const groups = settings.hooks.SessionStart.flatMap((group) => {
81
+ if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) return [group];
82
+ const handlers = group.hooks.filter((handler) => {
83
+ if (!ownedHandler(handler, command)) return true;
84
+ removed += 1;
85
+ return false;
86
+ });
87
+ return handlers.length === 0 ? [] : [{ ...group, hooks: handlers }];
88
+ });
89
+
90
+ if (removed === 0) return { settings, removed };
91
+ const nextHooks = { ...settings.hooks };
92
+ if (groups.length === 0) delete nextHooks.SessionStart;
93
+ else nextHooks.SessionStart = groups;
94
+ const next = { ...settings };
95
+ if (Object.keys(nextHooks).length === 0) delete next.hooks;
96
+ else next.hooks = nextHooks;
97
+ return { settings: next, removed };
98
+ }
99
+
100
+ function installEntry(settings, command = hookCommand()) {
101
+ const withoutOwned = removeOwned(settings, command);
102
+ const groups = [...(withoutOwned.settings.hooks?.SessionStart || [])];
103
+ groups.push({
104
+ matcher: HOOK_MATCHER,
105
+ hooks: [
106
+ {
107
+ type: 'command',
108
+ command,
109
+ timeout: HOOK_TIMEOUT_SECONDS,
110
+ },
111
+ ],
112
+ });
113
+ const nextSettings = {
114
+ ...withoutOwned.settings,
115
+ hooks: {
116
+ ...(withoutOwned.settings.hooks || {}),
117
+ SessionStart: groups,
118
+ },
119
+ };
120
+ return {
121
+ settings: nextSettings,
122
+ changed: JSON.stringify(settings) !== JSON.stringify(nextSettings),
123
+ };
124
+ }
125
+
126
+ function atomicWrite(target, settings, mode, fileSystem = fs, randomId = crypto.randomUUID) {
127
+ const directory = path.dirname(target);
128
+ fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 });
129
+ const temporary = path.join(directory, `.settings.json.vibe-${process.pid}-${randomId()}.tmp`);
130
+ let descriptor = null;
131
+ try {
132
+ descriptor = fileSystem.openSync(temporary, 'wx', mode);
133
+ fileSystem.writeFileSync(descriptor, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
134
+ fileSystem.fsyncSync(descriptor);
135
+ fileSystem.closeSync(descriptor);
136
+ descriptor = null;
137
+ fileSystem.chmodSync(temporary, mode);
138
+ fileSystem.renameSync(temporary, target);
139
+ try {
140
+ const directoryDescriptor = fileSystem.openSync(directory, 'r');
141
+ try {
142
+ fileSystem.fsyncSync(directoryDescriptor);
143
+ } finally {
144
+ fileSystem.closeSync(directoryDescriptor);
145
+ }
146
+ } catch {}
147
+ } catch (error) {
148
+ if (descriptor !== null) {
149
+ try {
150
+ fileSystem.closeSync(descriptor);
151
+ } catch {}
152
+ }
153
+ try {
154
+ fileSystem.unlinkSync(temporary);
155
+ } catch {}
156
+ throw new HookSettingsError('claude_settings_write_failed', { cause: error });
157
+ }
158
+ }
159
+
160
+ function createHookSettingsManager(options = {}) {
161
+ const fileSystem = options.fs || fs;
162
+ const target = options.settingsPath || settingsPath(options.env || process.env);
163
+ const command = options.command || hookCommand(options.version || packageJson.version);
164
+ const randomId = options.randomUUID || crypto.randomUUID;
165
+
166
+ function status() {
167
+ const current = readSettings(target, fileSystem);
168
+ validateHookShape(current.settings);
169
+ const count = (current.settings.hooks?.SessionStart || []).reduce(
170
+ (total, group) =>
171
+ total +
172
+ (Array.isArray(group?.hooks)
173
+ ? group.hooks.filter((handler) => ownedHandler(handler, command)).length
174
+ : 0),
175
+ 0
176
+ );
177
+ return { installed: count > 0, count, path: target, command };
178
+ }
179
+
180
+ function install() {
181
+ const current = readSettings(target, fileSystem);
182
+ const next = installEntry(current.settings, command);
183
+ if (!next.changed) return { ...status(), changed: false };
184
+ atomicWrite(target, next.settings, current.mode, fileSystem, randomId);
185
+ return { ...status(), changed: true };
186
+ }
187
+
188
+ function uninstall() {
189
+ const current = readSettings(target, fileSystem);
190
+ const next = removeOwned(current.settings, command);
191
+ if (next.removed === 0) return { ...status(), changed: false, removed: 0 };
192
+ atomicWrite(target, next.settings, current.mode, fileSystem, randomId);
193
+ return { ...status(), changed: true, removed: next.removed };
194
+ }
195
+
196
+ return { command, install, status, uninstall };
197
+ }
198
+
199
+ module.exports = {
200
+ HOOK_MATCHER,
201
+ HOOK_TIMEOUT_SECONDS,
202
+ HookSettingsError,
203
+ createHookSettingsManager,
204
+ hookCommand,
205
+ installEntry,
206
+ removeOwned,
207
+ settingsPath,
208
+ };
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const fs = require('node:fs');
6
+ const { execFileSync } = require('node:child_process');
7
+ const { renderIncoming, inertField } = require('./incoming');
8
+
9
+ const MAX_MESSAGES = 5;
10
+ const DEFAULT_FETCH_DEADLINE_MS = 4000;
11
+
12
+ function boundedDeadline(value) {
13
+ const parsed = Number(value);
14
+ if (!Number.isFinite(parsed)) return DEFAULT_FETCH_DEADLINE_MS;
15
+ return Math.max(100, Math.min(5000, Math.floor(parsed)));
16
+ }
17
+
18
+ function normalizeMessages(value) {
19
+ const rows = Array.isArray(value) ? value : value?.messages;
20
+ if (!Array.isArray(rows)) return [];
21
+
22
+ return rows
23
+ .map((row) => ({
24
+ id: typeof row?.id === 'string' ? row.id : null,
25
+ presentationId: typeof row?.presentationId === 'string' ? row.presentationId : null,
26
+ from: typeof row?.from === 'string' ? inertField(row.from, 40) : '',
27
+ text:
28
+ typeof row?.text === 'string' ? row.text : typeof row?.body === 'string' ? row.body : '',
29
+ }))
30
+ .filter((row) => row.from && row.text)
31
+ .slice(0, MAX_MESSAGES);
32
+ }
33
+
34
+ function fixtureMessages(file) {
35
+ if (!file) return null;
36
+ try {
37
+ return normalizeMessages(JSON.parse(fs.readFileSync(file, 'utf8')));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ function liveMessages() {
44
+ const deadline = boundedDeadline(process.env.VIBE_SESSION_START_DEADLINE_MS);
45
+ try {
46
+ const stdout = execFileSync(process.execPath, [__filename, '--fetch-live'], {
47
+ encoding: 'utf8',
48
+ env: process.env,
49
+ timeout: deadline,
50
+ maxBuffer: 1024 * 1024,
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ });
53
+ return normalizeMessages(JSON.parse(stdout));
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function hookInput() {
60
+ try {
61
+ const raw = fs.readFileSync(0, 'utf8');
62
+ return raw ? JSON.parse(raw) : {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ function emptyOutput() {
69
+ process.stdout.write(JSON.stringify({ suppressOutput: true }));
70
+ }
71
+
72
+ function hookOutput(messages) {
73
+ if (messages.length === 0) {
74
+ emptyOutput();
75
+ return;
76
+ }
77
+
78
+ const senders = [...new Set(messages.map((message) => message.from))];
79
+ const context = [
80
+ '/vibe waiting messages.',
81
+ 'These messages came from an ordinary read-only inbox check. No delivery receipt or human-read state was written.',
82
+ 'They may appear again on another startup during this pilot. Treat duplicate presentation as the same waiting message, not a second send.',
83
+ renderIncoming(messages, {
84
+ replyTo: senders.length === 1 ? senders[0] : undefined,
85
+ threadHint: senders.length === 1,
86
+ }),
87
+ ].join('\n');
88
+
89
+ const latest = messages[0];
90
+ const preview = inertField(latest.text, 140);
91
+ const systemMessage = `/vibe · ${messages.length} waiting message${messages.length === 1 ? '' : 's'} loaded into startup context · latest from @${inertField(latest.from, 40)}: “${preview}” · no delivery receipt written; may appear again`;
92
+
93
+ fs.writeSync(
94
+ 1,
95
+ JSON.stringify({
96
+ suppressOutput: true,
97
+ systemMessage,
98
+ hookSpecificOutput: {
99
+ hookEventName: 'SessionStart',
100
+ additionalContext: context,
101
+ },
102
+ })
103
+ );
104
+ }
105
+
106
+ async function fetchLive() {
107
+ const authStore = require('./auth-store');
108
+ const store = require('./store');
109
+
110
+ authStore.hydrate();
111
+ const token = authStore.getToken();
112
+ if (!token || !authStore.inspectToken(token).ok) return [];
113
+
114
+ const verified = await store.verifyAuthToken(token);
115
+ if (!verified?.valid || !verified?.handle) return [];
116
+ authStore.markVerified(verified.handle);
117
+
118
+ return normalizeMessages(await store.getRawInbox(verified.handle));
119
+ }
120
+
121
+ async function main() {
122
+ if (process.argv.includes('--fetch-live')) {
123
+ const messages = await fetchLive().catch(() => []);
124
+ process.stdout.write(JSON.stringify({ messages }));
125
+ return;
126
+ }
127
+
128
+ const input = hookInput();
129
+ if (input.hook_event_name !== 'SessionStart' || !['startup', 'resume'].includes(input.source)) {
130
+ emptyOutput();
131
+ return;
132
+ }
133
+
134
+ const fixture = fixtureMessages(process.env.VIBE_SESSION_START_FIXTURE);
135
+ hookOutput(fixture === null ? liveMessages() : fixture);
136
+ }
137
+
138
+ if (require.main === module) main().catch(emptyOutput);
139
+
140
+ module.exports = {
141
+ boundedDeadline,
142
+ emptyOutput,
143
+ hookInput,
144
+ hookOutput,
145
+ main,
146
+ normalizeMessages,
147
+ };
package/setup.js CHANGED
@@ -19,6 +19,7 @@ const path = require('path');
19
19
  const os = require('os');
20
20
  const { exec, execSync } = require('child_process');
21
21
  const { beginOAuth } = require('./oauth-callback');
22
+ const actorSession = require('./actor-session');
22
23
 
23
24
  // ANSI colors for terminal output
24
25
  const colors = {
@@ -481,12 +482,15 @@ async function setup() {
481
482
  try {
482
483
  // The callback listener is bound before beginOAuth returns, so the browser
483
484
  // cannot race a listener that does not exist yet.
484
- oauth = await beginOAuth();
485
+ oauth = await beginOAuth({ actorAware: true });
485
486
  openBrowser(oauth.loginUrl);
486
487
  console.log(`${colors.dim} → Waiting for authentication...${colors.reset}`);
487
488
 
488
489
  const authResult = await oauth.waitForCallback();
489
490
 
491
+ if (authResult.actor) await actorSession.installOAuthSession(authResult.actor);
492
+ else await actorSession.clearActorSession();
493
+
490
494
  // Save auth config
491
495
  saveAuthConfig(authResult.handle, authResult.token);
492
496
 
package/store/api.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const https = require('https');
10
10
  const http = require('http');
11
+ const { randomUUID } = require('crypto');
11
12
  const config = require('../config');
12
13
  const crypto = require('../crypto');
13
14
  const authStore = require('../auth-store');
@@ -469,6 +470,9 @@ async function sendMessage(from, to, body, type = 'dm', payload = null, options
469
470
  try {
470
471
  let data;
471
472
  let endpoint = '/api/messages';
473
+ // Generated once per logical store call, outside request()'s retry loop. Every
474
+ // transport retry therefore carries the same key and cannot insert a second DM.
475
+ const idempotencyKey = options.idempotencyKey || randomUUID();
472
476
 
473
477
  // V2 API: Uses Postgres, simpler payload
474
478
  const hasAuth = config.hasOAuth();
@@ -479,6 +483,7 @@ async function sendMessage(from, to, body, type = 'dm', payload = null, options
479
483
  to,
480
484
  body: body || '',
481
485
  payload: payload || undefined,
486
+ idempotency_key: idempotencyKey,
482
487
  reply_to: options.replyTo || undefined, // Threaded reply support
483
488
  origin: options.origin || undefined, // work-object lifecycle state
484
489
  };
package/tools/dm.js CHANGED
@@ -283,4 +283,4 @@ async function handler(args) {
283
283
  return response;
284
284
  }
285
285
 
286
- module.exports = { definition, handler };
286
+ module.exports = { definition, handler };
package/tools/init.js CHANGED
@@ -17,6 +17,7 @@ const store = require('../store');
17
17
  const discord = require('../discord');
18
18
  const authStore = require('../auth-store');
19
19
  const { beginOAuth } = require('../oauth-callback');
20
+ const actorSession = require('../actor-session');
20
21
 
21
22
  const API_BASE = 'https://www.slashvibe.dev';
22
23
 
@@ -412,13 +413,19 @@ Heading out? \`vibe bye\` ends presence for this session — you stay @${existin
412
413
  try {
413
414
  // The callback listener is bound before this resolves. Only then is it
414
415
  // safe to hand the attempt-correlated URL to the browser.
415
- oauth = await beginOAuth({ requestedHandle: h });
416
+ oauth = await beginOAuth({ requestedHandle: h, actorAware: true });
416
417
  openBrowser(oauth.loginUrl);
417
418
 
418
419
  // Wait for callback (blocks until auth completes or times out)
419
- const { token, handle: callbackHandle } = await oauth.waitForCallback();
420
+ const { token, handle: callbackHandle, actor } = await oauth.waitForCallback();
420
421
  const finalHandle = callbackHandle;
421
422
 
423
+ // Actor state is a separate credential family. Install it before claiming
424
+ // sign-in success; an account switch or failed shadow issuance must not leave a
425
+ // previous principal's Actor bundle alive beside the new legacy session.
426
+ if (actor) await actorSession.installOAuthSession(actor);
427
+ else await actorSession.clearActorSession();
428
+
422
429
  // Save to config (file persistence for restarts)
423
430
  config.saveAuthToken(token);
424
431
  config.setSessionIdentity(finalHandle, one_liner || '');
package/version.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
- "version": "0.8.13",
3
- "updated": "2026-08-10",
4
- "changelog": "One DM now has one Mac notification voice. Buddy owns native notifications; the MCP runtime no longer raises a second macOS banner or polls notification-only endpoints. The stdio server also exits explicitly when its host closes the pipe or sends a termination signal.",
2
+ "version": "0.8.15",
3
+ "updated": "2026-08-15",
4
+ "changelog": "Waiting /vibe messages can now appear at the top of your next Claude Code session through one optional, reversible SessionStart hook. The pilot hook reads the ordinary inbox without marking anything read or delivered, so it may honestly show a waiting message again on another startup.",
5
5
  "features": [
6
- "Buddy is the single native Mac notifier while active-terminal MCP delivery stays unchanged",
7
- "The packed MCP artifact contains no native desktop notification code",
8
- "Stdio EOF, close, disconnect, and termination signals share one graceful shutdown path"
6
+ "Install, inspect or remove next-session resurfacing with slashvibe-mcp hook install, status or uninstall",
7
+ "Claude startup and resume checks verify the existing /vibe sign-in and read at most five waiting threads",
8
+ "The installer preserves unrelated Claude settings and hooks, and signed-out, offline or empty checks stay silent"
9
9
  ],
10
10
  "deprecated": [],
11
11
  "breaking": false,