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 +19 -2
- package/actor-session.js +475 -0
- package/cli.js +12 -1
- package/hook-cli.js +54 -0
- package/index.js +18 -0
- package/oauth-callback.js +137 -5
- package/package.json +5 -1
- package/session-start-hook-settings.js +208 -0
- package/session-start-hook.cjs +147 -0
- package/setup.js +5 -1
- package/store/api.js +5 -0
- package/tools/dm.js +1 -1
- package/tools/init.js +9 -2
- package/version.json +6 -6
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.
|
|
47
|
-
|
|
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
|
|
package/actor-session.js
ADDED
|
@@ -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
|
|
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(
|
|
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.
|
|
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
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.
|
|
3
|
-
"updated": "2026-08-
|
|
4
|
-
"changelog": "
|
|
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
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
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,
|