hivecode-mcp 0.12.4 → 0.13.1

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/hive-repos.js ADDED
@@ -0,0 +1,430 @@
1
+ // hive-repos.js — Per-machine repo registry (~/.hivecode/repos.json),
2
+ // display-only room repo_holder record, and the hive_pr MCP handler (Part A2).
3
+ //
4
+ // Security invariant:
5
+ // What lands, and from where, is decided strictly on the local machine via
6
+ // ~/.hivecode/repos.json + git config verification. Room data (repo_holder)
7
+ // is display-only ({ branch, holder, host }) and never supplies a local path.
8
+ // Test commands and staleness overrides (force) are never accepted from MCP
9
+ // tool arguments; tests come only from testCommandFor(root, branch) in hive-land.js.
10
+
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { parseLink } from './sync.js';
15
+ import * as HB from './hive-branch.js';
16
+ import { landAndOpen, testCommandFor } from './hive-land.js';
17
+
18
+ export function reposFile() {
19
+ return path.join(HB.hiveHome(), 'repos.json');
20
+ }
21
+
22
+ export function loadRepos() {
23
+ try {
24
+ const raw = fs.readFileSync(reposFile(), 'utf8');
25
+ const parsed = JSON.parse(raw);
26
+ if (parsed && typeof parsed === 'object' && parsed.rooms && typeof parsed.rooms === 'object') {
27
+ return parsed;
28
+ }
29
+ } catch {}
30
+ return { rooms: {} };
31
+ }
32
+
33
+ function normalizeEntries(val) {
34
+ if (!val) return [];
35
+ if (Array.isArray(val)) return val.filter(e => e && typeof e === 'object' && e.root && e.branch);
36
+ if (typeof val === 'object' && val.root && val.branch) return [val];
37
+ return [];
38
+ }
39
+
40
+ export function saveRepos(data) {
41
+ try {
42
+ const file = reposFile();
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ const payload = JSON.stringify(data, null, 2);
45
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
46
+ fs.writeFileSync(tmp, payload);
47
+ try {
48
+ fs.renameSync(tmp, file);
49
+ } catch {
50
+ try { fs.writeFileSync(file, payload); } catch {}
51
+ try { fs.unlinkSync(tmp); } catch {}
52
+ }
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Register a local git repository + branch binding for a room on this machine.
61
+ * Never throws: a registry write failure must never fail bindBranch or hive_join.
62
+ *
63
+ * Accepts { room, root, cwd, branch, link }.
64
+ */
65
+ export function registerRepo(opts = {}) {
66
+ try {
67
+ const rawRoot = opts.root || opts.cwd || '';
68
+ const branch = opts.branch || '';
69
+ const link = opts.link || '';
70
+ const room = opts.room || (link ? parseLink(link).room : '');
71
+ if (!rawRoot || !branch || !room) return false;
72
+
73
+ const root = path.resolve(rawRoot);
74
+ const data = loadRepos();
75
+ const list = normalizeEntries(data.rooms[room]);
76
+ const entry = {
77
+ root,
78
+ branch,
79
+ link,
80
+ updatedAt: new Date().toISOString(),
81
+ };
82
+ const idx = list.findIndex(e => path.resolve(e.root) === root);
83
+ if (idx >= 0) list[idx] = entry;
84
+ else list.push(entry);
85
+ data.rooms[room] = list;
86
+ return saveRepos(data);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function isEntryValidForRoom(entry, roomId) {
93
+ try {
94
+ if (!entry || !entry.root || !entry.branch) return false;
95
+ const root = path.resolve(entry.root);
96
+ if (!fs.existsSync(root) || !HB.repoRoot(root)) return false;
97
+ const binding = HB.readBinding(root, entry.branch);
98
+ if (!binding || !binding.link) return false;
99
+ const boundRoom = parseLink(binding.link).room;
100
+ return boundRoom === roomId;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Look up the local repository bound to `room` on this machine.
108
+ * Verifies every candidate on disk against git config (`hivecode.<branch>.room`).
109
+ *
110
+ * Error codes:
111
+ * - Q583: no entry registered for this room on this machine
112
+ * - Q584: registered entry is stale (moved/deleted/rebound) OR multiple clones
113
+ * on this machine are bound to the same room and no preferredRoot matched
114
+ */
115
+ export function lookupRepo(roomOrLink, opts = {}) {
116
+ const roomId = parseLink(String(roomOrLink || '')).room || String(roomOrLink || '').trim();
117
+ if (!roomId) {
118
+ return {
119
+ ok: false,
120
+ code: 'Q583',
121
+ notFound: true,
122
+ entries: [],
123
+ why: 'Q583 — room identifier is missing; cannot resolve a local git repository.',
124
+ };
125
+ }
126
+
127
+ const data = loadRepos();
128
+ const rawList = normalizeEntries(data.rooms[roomId]);
129
+ const validEntries = [];
130
+ const staleEntries = [];
131
+
132
+ for (const e of rawList) {
133
+ const normalized = { ...e, root: path.resolve(e.root) };
134
+ if (isEntryValidForRoom(normalized, roomId)) {
135
+ validEntries.push(normalized);
136
+ } else {
137
+ staleEntries.push(normalized);
138
+ }
139
+ }
140
+
141
+ // If the current session joined by branch, also check its own branchCtx directly
142
+ if (opts.preferredRoot) {
143
+ const prefRoot = path.resolve(opts.preferredRoot);
144
+ const alreadyInValid = validEntries.find(e => path.resolve(e.root) === prefRoot);
145
+ if (alreadyInValid) {
146
+ return { ok: true, entry: alreadyInValid };
147
+ }
148
+ if (opts.preferredBranch) {
149
+ const candidate = { root: prefRoot, branch: opts.preferredBranch, link: opts.preferredLink || '' };
150
+ if (isEntryValidForRoom(candidate, roomId)) {
151
+ return { ok: true, entry: candidate };
152
+ }
153
+ }
154
+ }
155
+
156
+ if (validEntries.length === 1) {
157
+ return { ok: true, entry: validEntries[0] };
158
+ }
159
+
160
+ if (validEntries.length > 1) {
161
+ return {
162
+ ok: false,
163
+ code: 'Q584',
164
+ ambiguous: true,
165
+ entries: validEntries,
166
+ why:
167
+ `Q584 — ${validEntries.length} repositories on this machine are bound to room ${roomId}:\n` +
168
+ validEntries.map(e => ` - ${e.root} (branch ${e.branch})`).join('\n') +
169
+ `\nRefusing to pick one silently. Join by branch from the clone you want to land from (hive_join with branch + repo), or unbind the extra clone.`,
170
+ };
171
+ }
172
+
173
+ if (staleEntries.length > 0) {
174
+ return {
175
+ ok: false,
176
+ code: 'Q584',
177
+ stale: true,
178
+ entries: [],
179
+ why:
180
+ `Q584 — the registered repository for room ${roomId} on this machine (${staleEntries.map(e => `${e.root}#${e.branch}`).join(', ')}) ` +
181
+ `is no longer bound to this room or was moved/deleted. Re-bind with: npx -y hivecode-mcp@latest branch <branch> --join "<link>"`,
182
+ };
183
+ }
184
+
185
+ return {
186
+ ok: false,
187
+ code: 'Q583',
188
+ notFound: true,
189
+ entries: [],
190
+ why: `Q583 — room ${roomId} is not bound to a git branch on this machine.`,
191
+ };
192
+ }
193
+
194
+ const ALLOWED_HOLDER_KEYS = ['branch', 'holder', 'host', 'updatedAt'];
195
+
196
+ /**
197
+ * Write a display-only repo_holder record into the room Y.Doc.
198
+ * Strips all filesystem paths (root, cwd, dir, path). Never throws.
199
+ */
200
+ export function writeRepoHolder(doc, info = {}) {
201
+ try {
202
+ if (!doc || typeof doc.getMap !== 'function') return false;
203
+ const map = doc.getMap('repo_holder');
204
+ const branch = typeof info.branch === 'string' ? info.branch.trim() : '';
205
+ const holder = typeof (info.holder || info.name) === 'string' ? String(info.holder || info.name).trim() : '';
206
+ const host = typeof info.host === 'string' && info.host.trim() ? info.host.trim() : os.hostname();
207
+ const updatedAt = new Date().toISOString();
208
+
209
+ const apply = () => {
210
+ for (const k of Array.from(map.keys())) {
211
+ if (!ALLOWED_HOLDER_KEYS.includes(k)) map.delete(k);
212
+ }
213
+ if (branch) map.set('branch', branch);
214
+ if (holder) map.set('holder', holder);
215
+ if (host) map.set('host', host);
216
+ map.set('updatedAt', updatedAt);
217
+ };
218
+ if (typeof doc.transact === 'function') doc.transact(apply);
219
+ else apply();
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Read the display-only repo_holder record from the room Y.Doc.
228
+ * Returns only { branch, holder, host, updatedAt } (never any path field), or null.
229
+ */
230
+ export function readRepoHolder(doc) {
231
+ try {
232
+ if (!doc || typeof doc.getMap !== 'function') return null;
233
+ const map = doc.getMap('repo_holder');
234
+ const branch = typeof map.get('branch') === 'string' ? map.get('branch') : '';
235
+ const holder = typeof map.get('holder') === 'string' ? map.get('holder') : '';
236
+ const host = typeof map.get('host') === 'string' ? map.get('host') : '';
237
+ const updatedAt = typeof map.get('updatedAt') === 'string' ? map.get('updatedAt') : '';
238
+ if (!branch && !holder && !host) return null;
239
+ const out = { branch, holder, host };
240
+ if (updatedAt) out.updatedAt = updatedAt;
241
+ return out;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Factory for the hive_pr MCP tool handler.
249
+ * Accepts an injectable `land` function (defaults to `landAndOpen` from hive-land.js).
250
+ */
251
+ export function makeHivePr({
252
+ land = landAndOpen,
253
+ getSession,
254
+ lookup = lookupRepo,
255
+ readHolder = readRepoHolder,
256
+ register = registerRepo,
257
+ writeHolder = writeRepoHolder,
258
+ } = {}) {
259
+ return async function hivePr(args = {}) {
260
+ const s = typeof getSession === 'function' ? getSession() : null;
261
+ if (!s || !s.hive) {
262
+ return 'Q585 — not in a room. Call hive_join first.';
263
+ }
264
+
265
+ const st = typeof s.hive.status === 'function' ? s.hive.status() : {};
266
+ if (st && st.connected === false) {
267
+ return 'Q586 — room connection is currently offline; wait for reconnect before running hive_pr.';
268
+ }
269
+
270
+ const linkStr = s.inviteLink || (s.relay && s.room ? `${s.relay}|${s.room}` : '');
271
+ const roomId = s.room || parseLink(linkStr).room;
272
+ const found = lookup(roomId, {
273
+ preferredRoot: s.branchCtx?.root,
274
+ preferredBranch: s.branchCtx?.branch,
275
+ preferredLink: linkStr,
276
+ });
277
+
278
+ if (!found || !found.ok) {
279
+ const holder = readHolder(s.hive.doc);
280
+ if (found?.code === 'Q584') {
281
+ const extra = holder && (holder.holder || holder.host)
282
+ ? `\nRoom repo holder record: ${holder.holder || 'unknown'} (${holder.host || 'unknown'}) on branch \`${holder.branch || 'unknown'}\`.`
283
+ : '';
284
+ return `${found.why}${extra}`;
285
+ }
286
+ if (holder && (holder.holder || holder.host)) {
287
+ const who = holder.holder || 'another member';
288
+ const where = holder.host || 'another machine';
289
+ const br = holder.branch || 'unknown';
290
+ return (
291
+ `Q582 — this room's repository is not on this machine. ` +
292
+ `The repo for branch \`${br}\` is on ${who} (${where}). ` +
293
+ `Ask @${who} to run hive_pr, or run it on ${where}.`
294
+ );
295
+ }
296
+ return (
297
+ found?.why ||
298
+ `Q583 — room ${roomId || '(unknown)'} is not bound to a git branch on this machine. ` +
299
+ `Bind a repository first with: npx -y hivecode-mcp@latest branch <branch> --join "${linkStr || '<link>'}"`
300
+ );
301
+ }
302
+
303
+ // Strict boolean check: only `confirm: true` lands/pushes/opens a PR.
304
+ // Security: `force`, `test`, and `testMinutes` are never accepted from MCP callers;
305
+ // test command comes from testCommandFor(root, branch) in hive-land.js (saved git config or branch-tip package.json).
306
+ const confirmed = args?.confirm === true;
307
+ const by = s.owner ? `${s.name} for ${s.owner}` : s.name;
308
+ const configuredTest = testCommandFor(found.entry.root, found.entry.branch).command;
309
+
310
+ const res = await land({
311
+ cwd: found.entry.root,
312
+ branch: found.entry.branch,
313
+ dry: !confirmed,
314
+ push: confirmed,
315
+ pr: confirmed,
316
+ base: args?.base,
317
+ test: configuredTest,
318
+ title: args?.title,
319
+ summary: args?.summary,
320
+ by,
321
+ allowWorkflowChanges: args?.allowWorkflowChanges === true,
322
+ only: args?.only,
323
+ });
324
+
325
+ const lines = [];
326
+
327
+ if (!res || !res.ok) {
328
+ if (res?.why) {
329
+ lines.push(res.why);
330
+ } else {
331
+ lines.push(`hive_pr refused${res?.code ? ` (${res.code})` : ''}.`);
332
+ }
333
+
334
+ // Check if caller holds claims itself on Q575
335
+ const holders = Array.isArray(res?.holders) ? res.holders : [];
336
+ const selfHeld = holders.filter(h => h && h.by === s.name).map(h => h.file);
337
+ const otherHeld = holders.filter(h => h && h.by !== s.name);
338
+ if (selfHeld.length > 0) {
339
+ for (const f of selfHeld) {
340
+ lines.push(`Note: you hold ${f}, release it first (call hive_release).`);
341
+ }
342
+ }
343
+ if (otherHeld.length > 0) {
344
+ lines.push(
345
+ `Held by teammates: ` +
346
+ otherHeld.map(h => `${h.file} (by ${h.by}${h.intent ? `: ${h.intent}` : ''})`).join(', ')
347
+ );
348
+ }
349
+
350
+ if (Array.isArray(res?.orphans) && res.orphans.length > 0) {
351
+ lines.push(
352
+ `Orphan files (changed in room with no verified receipt — will NOT land): ` +
353
+ res.orphans.join(', ')
354
+ );
355
+ }
356
+ return lines.join('\n');
357
+ }
358
+
359
+ // Success (either dry-run or confirmed landing)
360
+ if (!confirmed) {
361
+ lines.push(`DRY RUN — hive_pr plan for branch \`${found.entry.branch}\` in ${found.entry.root}:`);
362
+ if (res.why) lines.push(res.why);
363
+ const plan = Array.isArray(res.plan) ? res.plan : [];
364
+ if (plan.length === 0) {
365
+ if (!res.why) {
366
+ lines.push(' (no receipt-verified file changes to land)');
367
+ }
368
+ } else {
369
+ for (const group of plan) {
370
+ const files = Array.isArray(group.files) ? group.files.join(', ') : '';
371
+ const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
372
+ lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
373
+ }
374
+ }
375
+ if (Array.isArray(res.deletions) && res.deletions.length > 0) {
376
+ lines.push(` • Deletions: ${res.deletions.join(', ')}`);
377
+ }
378
+ if (Array.isArray(res.orphans) && res.orphans.length > 0) {
379
+ lines.push(
380
+ `⚠️ Orphans (changed in room with no verified receipt — will NOT land): ` +
381
+ res.orphans.join(', ')
382
+ );
383
+ }
384
+ lines.push(`\nNothing was committed or pushed. Call hive_pr with confirm: true to land, push, and open/update the PR.`);
385
+ return lines.join('\n');
386
+ }
387
+
388
+ // Confirmed landing succeeded: refresh local registry + room holder record
389
+ register({
390
+ room: roomId,
391
+ root: found.entry.root,
392
+ branch: found.entry.branch,
393
+ link: linkStr || found.entry.link,
394
+ });
395
+ writeHolder(s.hive.doc, {
396
+ branch: found.entry.branch,
397
+ holder: s.name,
398
+ host: os.hostname(),
399
+ });
400
+
401
+ lines.push(`LANDED on branch \`${found.entry.branch}\`${res.tip ? ` (tip ${res.tip})` : ''}.`);
402
+ if (res.why) lines.push(res.why);
403
+ const plan = Array.isArray(res.plan) ? res.plan : [];
404
+ for (const group of plan) {
405
+ const files = Array.isArray(group.files) ? group.files.join(', ') : '';
406
+ const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
407
+ lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
408
+ }
409
+ if (res.tests?.ran) {
410
+ lines.push(` • Tests: ${res.tests.command} -> ${res.tests.ok ? 'PASS' : 'FAIL'} (${res.tests.seconds ?? 0}s)`);
411
+ }
412
+ if (Array.isArray(res.orphans) && res.orphans.length > 0) {
413
+ lines.push(
414
+ `⚠️ Orphans (changed in room with no verified receipt — did NOT land): ` +
415
+ res.orphans.join(', ')
416
+ );
417
+ }
418
+ if (res.pr?.url) {
419
+ const action = res.pr.created ? 'opened' : 'updated';
420
+ lines.push(` • PR #${res.pr.number || ''} ${action}: ${res.pr.url}`);
421
+ try {
422
+ s.hive.say(
423
+ `PR ${action} for branch \`${found.entry.branch}\`: ${res.pr.url}` +
424
+ (res.tip ? ` (tip ${res.tip})` : '')
425
+ );
426
+ } catch {}
427
+ }
428
+ return lines.join('\n');
429
+ };
430
+ }
@@ -0,0 +1,209 @@
1
+ // PERSONAL ROOMS (8 Oct 2026): a room that only the people its host invites can enter.
2
+ //
3
+ // The relay already enforces this for any room whose id is "hs_<fp>_<rand>". fp is a fingerprint of
4
+ // the host's public key, every token for the room carries that key, and only the matching private
5
+ // key can sign one (server.js authorize). So a stranger holding the room's name gets nothing, even
6
+ // on the open public relay. What was missing was a way for an AGENT to make such a room: until now
7
+ // only the VS Code extension could, holding the key in the editor's secret storage.
8
+ //
9
+ // THE KEY NEVER LIVES IN A ROOM FOLDER. It sits in ~/.hivecode/keys/<room>.json, outside every
10
+ // folder the sync engine shares, so no client -- old, new or misconfigured -- can publish it.
11
+ // Whoever holds that file owns the room: lose it and nobody can invite again; copy it and the copy
12
+ // can invite too.
13
+ //
14
+ // INVITES ARE A COMMAND, NOT A TOOL. Every tool definition is paid on every request by every agent
15
+ // (tests/token-budget-test.mjs holds that to 3,500 tokens), so inviting -- something a host does a
16
+ // few times a week -- is `hivecode-mcp invite <name>`, run by the person or by an agent that has a
17
+ // shell. One link per person, carrying a token minted for them alone: an invite IS that person,
18
+ // which is why it is never pasted into the room chat (hive_say refuses tokens).
19
+ import fs from 'fs'
20
+ import os from 'os'
21
+ import path from 'path'
22
+ import crypto from 'crypto'
23
+ import { sign, decodeUnsafe, keyFingerprint, roomFingerprint, isSecuredRoom, makeSecuredRoomId } from './token.js'
24
+ import { validateMemberName } from './hive-mailbox.js'
25
+
26
+ export const keysDir = () => process.env.HIVE_KEYS_DIR || path.join(os.homedir(), '.hivecode', 'keys')
27
+ const keyFile = (room) => path.join(keysDir(), `${room}.json`)
28
+ const DAY = 86400
29
+ const nowSec = () => Math.floor(Date.now() / 1000)
30
+
31
+ /** A new personal room, in memory only: nothing is written until the join is allowed to go ahead. */
32
+ export function newPersonalRoom() {
33
+ const kp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
34
+ const publicKey = kp.publicKey.export({ type: 'spki', format: 'pem' })
35
+ const privateKey = kp.privateKey.export({ type: 'pkcs8', format: 'pem' })
36
+ return { room: makeSecuredRoomId(publicKey, crypto.randomBytes(6).toString('hex')), publicKey, privateKey }
37
+ }
38
+
39
+ export function saveRoomKey(k, { relay, host }) {
40
+ fs.mkdirSync(keysDir(), { recursive: true, mode: 0o700 })
41
+ const rec = { room: k.room, relay, host, publicKey: k.publicKey, privateKey: k.privateKey, created: Date.now(), invites: [] }
42
+ // 'wx': a fresh room never overwrites a key that exists, which would orphan the room it opened.
43
+ fs.writeFileSync(keyFile(k.room), JSON.stringify(rec, null, 2), { mode: 0o600, flag: 'wx' })
44
+ return rec
45
+ }
46
+
47
+ /** The key record for a personal room this machine hosts, or null. A file whose key does not match
48
+ * the room's fingerprint is treated as absent: it would only mint tokens the relay refuses. */
49
+ export function loadRoomKey(room) {
50
+ if (!isSecuredRoom(room)) return null
51
+ try {
52
+ const rec = JSON.parse(fs.readFileSync(keyFile(room), 'utf8'))
53
+ return rec && rec.privateKey && keyFingerprint(rec.publicKey) === roomFingerprint(room) ? rec : null
54
+ } catch { return null }
55
+ }
56
+ const writeRec = (rec) => fs.writeFileSync(keyFile(rec.room), JSON.stringify(rec, null, 2), { mode: 0o600 })
57
+
58
+ /** The same claims the extension mints (extension.js mintToken), so either can host and the relay
59
+ * cannot tell them apart. paths = what they may SEE; writePaths = the part of it they may EDIT. */
60
+ export function mintRoomToken(rec, { name, kind = 'ai', role = 'writer', paths, writePaths, owner, ttlSec = 7 * DAY }) {
61
+ const now = nowSec()
62
+ const jti = 'jti-' + crypto.randomBytes(9).toString('base64url')
63
+ const payload = {
64
+ iss: 'hivecode', sub: name, name, kind, ...(owner ? { owner } : {}),
65
+ pk: rec.publicKey, // self-certifying: the relay binds this key to the room id
66
+ scopes: [{ room: rec.room, role, ...(paths && paths.length ? { paths } : {}), ...(writePaths && writePaths.length ? { writePaths } : {}) }],
67
+ iat: now, exp: now + ttlSec, jti,
68
+ }
69
+ return { token: sign(payload, { privateKey: rec.privateKey }), jti, exp: payload.exp }
70
+ }
71
+
72
+ /** The host's own token, minted afresh when it is missing or within an hour of running out: the host
73
+ * holds the key, so its own access must never lapse. Only for the name the room was hosted under --
74
+ * another participant on this machine, joining with its own invite, keeps its own narrower grant. */
75
+ export function hostToken(room, name, current, { kind = 'ai', owner = '' } = {}) {
76
+ const rec = loadRoomKey(room)
77
+ if (!rec || rec.host !== name) return current
78
+ const p = current ? decodeUnsafe(current) : null
79
+ const fresh = !!(p && p.sub === name && p.exp > nowSec() + 3600 && p.pk && keyFingerprint(p.pk) === roomFingerprint(room))
80
+ return fresh ? current : mintRoomToken(rec, { name, kind, role: 'maintainer', owner, ttlSec: 30 * DAY }).token
81
+ }
82
+
83
+ // A path a token may name: relative, no "..", no drive or root. Globs are fine ("src/**", "!**/*.env").
84
+ function badPath(g) {
85
+ const s = String(g).replace(/^!/, '')
86
+ return !s || s.length > 200 || /^([A-Za-z]:|[\\/])/.test(s) || /(^|[\\/])\.\.([\\/]|$)/.test(s) || /[\x00-\x1f]/.test(s)
87
+ }
88
+ const list = (v) => (v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean))
89
+ const when = (sec) => new Date(sec * 1000).toISOString().slice(0, 10)
90
+
91
+ function whatTheyGet(i) {
92
+ const see = i.paths && i.paths.length ? i.paths.join(', ') : 'every file in the room'
93
+ const edit = i.role === 'reader' ? 'read only' : i.writePaths && i.writePaths.length ? `may edit ${i.writePaths.join(', ')}` : 'may edit what they see'
94
+ return `${i.kind}, ${i.role}: sees ${see}; ${edit}`
95
+ }
96
+
97
+ /** Make one person's invite. Returns { ok, text, link?, jti? }. */
98
+ export function makeInvite({ room, relay, name, kind = 'ai', reader = false, paths = [], writePaths = [], days = 7, owner = '' }) {
99
+ const rec = loadRoomKey(room)
100
+ if (!rec) {
101
+ return { ok: false, text: isSecuredRoom(room)
102
+ ? `Only the machine that hosted this personal room can invite people to it, and this one does not hold its key (${keysDir()}). Ask the host.`
103
+ : `Room ${room} is open: anyone with its link can join, so there is nothing to invite into. For a room only invited people can enter, host one with hive_join and private: true.` }
104
+ }
105
+ const nc = validateMemberName(name)
106
+ if (!nc.ok) return { ok: false, text: `${nc.error}\nNothing was minted.` }
107
+ if (name === rec.host) return { ok: false, text: `"${name}" is the host of this room. Pick the invitee's own name.\nNothing was minted.` }
108
+ const bad = [...paths, ...writePaths].filter(badPath)
109
+ if (bad.length) return { ok: false, text: `These paths cannot go in an invite (they must be relative, with no ".."): ${bad.join(', ')}\nNothing was minted.` }
110
+ const d = Number(days)
111
+ if (!Number.isFinite(d) || d <= 0 || d > 30) return { ok: false, text: `--days must be between 1 and 30 (got ${days}). A short invite is one you never have to remember to take back.\nNothing was minted.` }
112
+ const k = kind === 'human' ? 'human' : 'ai'
113
+ const role = reader ? 'reader' : 'writer'
114
+ const { token, jti, exp } = mintRoomToken(rec, { name, kind: k, role, paths, writePaths, owner, ttlSec: Math.round(d * DAY) })
115
+ const inv = { jti, name, kind: k, role, paths: paths.length ? paths : null, writePaths: writePaths.length ? writePaths : null, exp, at: Date.now() }
116
+ rec.invites = [...(rec.invites || []), inv]
117
+ writeRec(rec)
118
+ const link = `${relay || rec.relay}|${room}|${token}`
119
+ const text = `INVITE for ${name} (${whatTheyGet(inv)}), until ${when(exp)}.
120
+ Send it to ${name} directly, never in the room chat: whoever holds this link joins as ${name}.
121
+
122
+ Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:
123
+ npx -y hivecode-mcp@latest setup "${link}"
124
+
125
+ Take it back any time: npx -y hivecode-mcp@latest revoke ${name}`
126
+ return { ok: true, text, link, jti }
127
+ }
128
+
129
+ const httpBase = (relay) => String(relay).replace(/^ws(s?):\/\//i, 'http$1://').replace(/\/+$/, '')
130
+
131
+ /** Take back every live invite for a name. The relay refuses those tokens from then on and closes any
132
+ * connection using them. Returns { ok, text }. */
133
+ export async function revokeInvite({ room, relay, name }) {
134
+ const rec = loadRoomKey(room)
135
+ if (!rec) return { ok: false, text: `This machine does not host room ${room}, so it cannot take invites back. Only the host can.` }
136
+ const live = (rec.invites || []).filter((i) => i.name === name && !i.revokedAt)
137
+ if (!live.length) {
138
+ const names = [...new Set((rec.invites || []).filter((i) => !i.revokedAt).map((i) => i.name))]
139
+ return { ok: false, text: `No live invite for "${name}" in this room.${names.length ? ` Invited: ${names.join(', ')}.` : ' Nobody has been invited yet.'}` }
140
+ }
141
+ const owner = mintRoomToken(rec, { name: rec.host, role: 'maintainer', ttlSec: 600 }).token
142
+ let done = 0, dropped = 0
143
+ const failures = []
144
+ for (const i of live) {
145
+ try {
146
+ const r = await fetch(`${httpBase(relay || rec.relay)}/__hive/revoke`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, token: owner, jti: i.jti }) })
147
+ const body = await r.json().catch(() => ({}))
148
+ if (r.ok && body.ok) { i.revokedAt = Date.now(); done++; dropped += Number(body.dropped) || 0 }
149
+ else failures.push(`HTTP ${r.status}${body.error ? ` ${body.error}` : ''}`)
150
+ } catch (e) { failures.push(e.message) }
151
+ }
152
+ writeRec(rec)
153
+ if (!done) return { ok: false, text: `Could not reach the relay to revoke ${name}'s invite (${failures.join('; ')}). Nothing changed; try again.` }
154
+ return { ok: true, text: `Revoked ${done} invite(s) for ${name}. The relay refuses them from now on${dropped ? `, and closed ${dropped} connection(s) using them` : ''}.${failures.length ? `\n${failures.length} more could not be revoked (${failures.join('; ')}); run this again.` : ''}` }
155
+ }
156
+
157
+ // ---- the command line: hivecode-mcp invite <name> ... | hivecode-mcp revoke <name> ----
158
+
159
+ function parseArgs(argv) {
160
+ const o = { _: [] }
161
+ for (let i = 0; i < argv.length; i++) {
162
+ const a = argv[i]
163
+ if (!a.startsWith('--')) { o._.push(a); continue }
164
+ const key = a.slice(2)
165
+ if (['reader', 'human'].includes(key)) o[key] = true
166
+ else o[key] = argv[++i]
167
+ }
168
+ return o
169
+ }
170
+
171
+ // Which room: --dir's .hive.json, else this folder's, else the only room this machine hosts.
172
+ function findRoom(o) {
173
+ const dir = path.resolve(o.dir || '.')
174
+ try {
175
+ const c = JSON.parse(fs.readFileSync(path.join(dir, '.hive.json'), 'utf8'))
176
+ if (c && c.room) return { room: c.room, relay: c.relay || '' }
177
+ } catch { /* not a room folder */ }
178
+ let mine = []
179
+ try { mine = fs.readdirSync(keysDir()).filter((f) => f.endsWith('.json')).map((f) => loadRoomKey(f.slice(0, -5))).filter(Boolean) } catch { /* no keys yet */ }
180
+ if (mine.length === 1) return { room: mine[0].room, relay: mine[0].relay }
181
+ return { error: mine.length
182
+ ? `${dir} is not a room folder, and this machine hosts ${mine.length} personal rooms. Say which with --dir <the room's folder>:\n${mine.map((r) => ` ${r.room} (host ${r.host}, created ${new Date(r.created).toISOString().slice(0, 10)})`).join('\n')}`
183
+ : `${dir} is not a room folder, and this machine hosts no personal room. Host one first: hive_join with private: true.` }
184
+ }
185
+
186
+ const USAGE = `Usage:
187
+ hivecode-mcp invite <name> [--reader] [--human] [--paths a,b] [--edit a,b] [--days 7] [--owner <their human>] [--dir <room folder>]
188
+ hivecode-mcp revoke <name> [--dir <room folder>]
189
+
190
+ --reader they can read but not edit --paths only these folders/globs are visible to them
191
+ --human the invitee is a person, not AI --edit of what they see, only these may be edited
192
+ --days how long the invite lasts (default 7, at most 30)`
193
+
194
+ export async function main(argv) {
195
+ const cmd = argv[0]
196
+ const o = parseArgs(argv.slice(1))
197
+ const name = o._[0]
198
+ if (!name) { console.log(USAGE); return 2 }
199
+ const where = findRoom(o)
200
+ if (where.error) { console.log(where.error); return 1 }
201
+ if (cmd === 'revoke') {
202
+ const r = await revokeInvite({ room: where.room, relay: where.relay, name })
203
+ console.log(r.text)
204
+ return r.ok ? 0 : 1
205
+ }
206
+ const r = makeInvite({ room: where.room, relay: where.relay, name, kind: o.human ? 'human' : 'ai', reader: !!o.reader, paths: list(o.paths), writePaths: list(o.edit), days: o.days == null ? 7 : o.days, owner: o.owner || '' })
207
+ console.log(r.text)
208
+ return r.ok ? 0 : 1
209
+ }