hivecode-mcp 0.14.1 → 0.14.2

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.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
package/hive-room-key.js CHANGED
@@ -1,210 +1,210 @@
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
- import { relayFetch } from './hive-transport.js' // through the proxy, as every call to the relay
26
-
27
- export const keysDir = () => process.env.HIVE_KEYS_DIR || path.join(os.homedir(), '.hivecode', 'keys')
28
- const keyFile = (room) => path.join(keysDir(), `${room}.json`)
29
- const DAY = 86400
30
- const nowSec = () => Math.floor(Date.now() / 1000)
31
-
32
- /** A new personal room, in memory only: nothing is written until the join is allowed to go ahead. */
33
- export function newPersonalRoom() {
34
- const kp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
35
- const publicKey = kp.publicKey.export({ type: 'spki', format: 'pem' })
36
- const privateKey = kp.privateKey.export({ type: 'pkcs8', format: 'pem' })
37
- return { room: makeSecuredRoomId(publicKey, crypto.randomBytes(6).toString('hex')), publicKey, privateKey }
38
- }
39
-
40
- export function saveRoomKey(k, { relay, host }) {
41
- fs.mkdirSync(keysDir(), { recursive: true, mode: 0o700 })
42
- const rec = { room: k.room, relay, host, publicKey: k.publicKey, privateKey: k.privateKey, created: Date.now(), invites: [] }
43
- // 'wx': a fresh room never overwrites a key that exists, which would orphan the room it opened.
44
- fs.writeFileSync(keyFile(k.room), JSON.stringify(rec, null, 2), { mode: 0o600, flag: 'wx' })
45
- return rec
46
- }
47
-
48
- /** The key record for a personal room this machine hosts, or null. A file whose key does not match
49
- * the room's fingerprint is treated as absent: it would only mint tokens the relay refuses. */
50
- export function loadRoomKey(room) {
51
- if (!isSecuredRoom(room)) return null
52
- try {
53
- const rec = JSON.parse(fs.readFileSync(keyFile(room), 'utf8'))
54
- return rec && rec.privateKey && keyFingerprint(rec.publicKey) === roomFingerprint(room) ? rec : null
55
- } catch { return null }
56
- }
57
- const writeRec = (rec) => fs.writeFileSync(keyFile(rec.room), JSON.stringify(rec, null, 2), { mode: 0o600 })
58
-
59
- /** The same claims the extension mints (extension.js mintToken), so either can host and the relay
60
- * cannot tell them apart. paths = what they may SEE; writePaths = the part of it they may EDIT. */
61
- export function mintRoomToken(rec, { name, kind = 'ai', role = 'writer', paths, writePaths, owner, ttlSec = 7 * DAY }) {
62
- const now = nowSec()
63
- const jti = 'jti-' + crypto.randomBytes(9).toString('base64url')
64
- const payload = {
65
- iss: 'hivecode', sub: name, name, kind, ...(owner ? { owner } : {}),
66
- pk: rec.publicKey, // self-certifying: the relay binds this key to the room id
67
- scopes: [{ room: rec.room, role, ...(paths && paths.length ? { paths } : {}), ...(writePaths && writePaths.length ? { writePaths } : {}) }],
68
- iat: now, exp: now + ttlSec, jti,
69
- }
70
- return { token: sign(payload, { privateKey: rec.privateKey }), jti, exp: payload.exp }
71
- }
72
-
73
- /** The host's own token, minted afresh when it is missing or within an hour of running out: the host
74
- * holds the key, so its own access must never lapse. Only for the name the room was hosted under --
75
- * another participant on this machine, joining with its own invite, keeps its own narrower grant. */
76
- export function hostToken(room, name, current, { kind = 'ai', owner = '' } = {}) {
77
- const rec = loadRoomKey(room)
78
- if (!rec || rec.host !== name) return current
79
- const p = current ? decodeUnsafe(current) : null
80
- const fresh = !!(p && p.sub === name && p.exp > nowSec() + 3600 && p.pk && keyFingerprint(p.pk) === roomFingerprint(room))
81
- return fresh ? current : mintRoomToken(rec, { name, kind, role: 'maintainer', owner, ttlSec: 30 * DAY }).token
82
- }
83
-
84
- // A path a token may name: relative, no "..", no drive or root. Globs are fine ("src/**", "!**/*.env").
85
- function badPath(g) {
86
- const s = String(g).replace(/^!/, '')
87
- return !s || s.length > 200 || /^([A-Za-z]:|[\\/])/.test(s) || /(^|[\\/])\.\.([\\/]|$)/.test(s) || /[\x00-\x1f]/.test(s)
88
- }
89
- const list = (v) => (v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean))
90
- const when = (sec) => new Date(sec * 1000).toISOString().slice(0, 10)
91
-
92
- function whatTheyGet(i) {
93
- const see = i.paths && i.paths.length ? i.paths.join(', ') : 'every file in the room'
94
- const edit = i.role === 'reader' ? 'read only' : i.writePaths && i.writePaths.length ? `may edit ${i.writePaths.join(', ')}` : 'may edit what they see'
95
- return `${i.kind}, ${i.role}: sees ${see}; ${edit}`
96
- }
97
-
98
- /** Make one person's invite. Returns { ok, text, link?, jti? }. */
99
- export function makeInvite({ room, relay, name, kind = 'ai', reader = false, paths = [], writePaths = [], days = 7, owner = '' }) {
100
- const rec = loadRoomKey(room)
101
- if (!rec) {
102
- return { ok: false, text: isSecuredRoom(room)
103
- ? `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.`
104
- : `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.` }
105
- }
106
- const nc = validateMemberName(name)
107
- if (!nc.ok) return { ok: false, text: `${nc.error}\nNothing was minted.` }
108
- if (name === rec.host) return { ok: false, text: `"${name}" is the host of this room. Pick the invitee's own name.\nNothing was minted.` }
109
- const bad = [...paths, ...writePaths].filter(badPath)
110
- 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.` }
111
- const d = Number(days)
112
- 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.` }
113
- const k = kind === 'human' ? 'human' : 'ai'
114
- const role = reader ? 'reader' : 'writer'
115
- const { token, jti, exp } = mintRoomToken(rec, { name, kind: k, role, paths, writePaths, owner, ttlSec: Math.round(d * DAY) })
116
- const inv = { jti, name, kind: k, role, paths: paths.length ? paths : null, writePaths: writePaths.length ? writePaths : null, exp, at: Date.now() }
117
- rec.invites = [...(rec.invites || []), inv]
118
- writeRec(rec)
119
- const link = `${relay || rec.relay}|${room}|${token}`
120
- const text = `INVITE for ${name} (${whatTheyGet(inv)}), until ${when(exp)}.
121
- Send it to ${name} directly, never in the room chat: whoever holds this link joins as ${name}.
122
-
123
- Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:
124
- npx -y hivecode-mcp@latest setup "${link}"
125
-
126
- Take it back any time: npx -y hivecode-mcp@latest revoke ${name}`
127
- return { ok: true, text, link, jti }
128
- }
129
-
130
- const httpBase = (relay) => String(relay).replace(/^ws(s?):\/\//i, 'http$1://').replace(/\/+$/, '')
131
-
132
- /** Take back every live invite for a name. The relay refuses those tokens from then on and closes any
133
- * connection using them. Returns { ok, text }. */
134
- export async function revokeInvite({ room, relay, name }) {
135
- const rec = loadRoomKey(room)
136
- if (!rec) return { ok: false, text: `This machine does not host room ${room}, so it cannot take invites back. Only the host can.` }
137
- const live = (rec.invites || []).filter((i) => i.name === name && !i.revokedAt)
138
- if (!live.length) {
139
- const names = [...new Set((rec.invites || []).filter((i) => !i.revokedAt).map((i) => i.name))]
140
- return { ok: false, text: `No live invite for "${name}" in this room.${names.length ? ` Invited: ${names.join(', ')}.` : ' Nobody has been invited yet.'}` }
141
- }
142
- const owner = mintRoomToken(rec, { name: rec.host, role: 'maintainer', ttlSec: 600 }).token
143
- let done = 0, dropped = 0
144
- const failures = []
145
- for (const i of live) {
146
- try {
147
- const r = await relayFetch(`${httpBase(relay || rec.relay)}/__hive/revoke`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, token: owner, jti: i.jti }) })
148
- const body = await r.json().catch(() => ({}))
149
- if (r.ok && body.ok) { i.revokedAt = Date.now(); done++; dropped += Number(body.dropped) || 0 }
150
- else failures.push(`HTTP ${r.status}${body.error ? ` ${body.error}` : ''}`)
151
- } catch (e) { failures.push(e.message) }
152
- }
153
- writeRec(rec)
154
- if (!done) return { ok: false, text: `Could not reach the relay to revoke ${name}'s invite (${failures.join('; ')}). Nothing changed; try again.` }
155
- 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.` : ''}` }
156
- }
157
-
158
- // ---- the command line: hivecode-mcp invite <name> ... | hivecode-mcp revoke <name> ----
159
-
160
- function parseArgs(argv) {
161
- const o = { _: [] }
162
- for (let i = 0; i < argv.length; i++) {
163
- const a = argv[i]
164
- if (!a.startsWith('--')) { o._.push(a); continue }
165
- const key = a.slice(2)
166
- if (['reader', 'human'].includes(key)) o[key] = true
167
- else o[key] = argv[++i]
168
- }
169
- return o
170
- }
171
-
172
- // Which room: --dir's .hive.json, else this folder's, else the only room this machine hosts.
173
- function findRoom(o) {
174
- const dir = path.resolve(o.dir || '.')
175
- try {
176
- const c = JSON.parse(fs.readFileSync(path.join(dir, '.hive.json'), 'utf8'))
177
- if (c && c.room) return { room: c.room, relay: c.relay || '' }
178
- } catch { /* not a room folder */ }
179
- let mine = []
180
- try { mine = fs.readdirSync(keysDir()).filter((f) => f.endsWith('.json')).map((f) => loadRoomKey(f.slice(0, -5))).filter(Boolean) } catch { /* no keys yet */ }
181
- if (mine.length === 1) return { room: mine[0].room, relay: mine[0].relay }
182
- return { error: mine.length
183
- ? `${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')}`
184
- : `${dir} is not a room folder, and this machine hosts no personal room. Host one first: hive_join with private: true.` }
185
- }
186
-
187
- const USAGE = `Usage:
188
- hivecode-mcp invite <name> [--reader] [--human] [--paths a,b] [--edit a,b] [--days 7] [--owner <their human>] [--dir <room folder>]
189
- hivecode-mcp revoke <name> [--dir <room folder>]
190
-
191
- --reader they can read but not edit --paths only these folders/globs are visible to them
192
- --human the invitee is a person, not AI --edit of what they see, only these may be edited
193
- --days how long the invite lasts (default 7, at most 30)`
194
-
195
- export async function main(argv) {
196
- const cmd = argv[0]
197
- const o = parseArgs(argv.slice(1))
198
- const name = o._[0]
199
- if (!name) { console.log(USAGE); return 2 }
200
- const where = findRoom(o)
201
- if (where.error) { console.log(where.error); return 1 }
202
- if (cmd === 'revoke') {
203
- const r = await revokeInvite({ room: where.room, relay: where.relay, name })
204
- console.log(r.text)
205
- return r.ok ? 0 : 1
206
- }
207
- 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 || '' })
208
- console.log(r.text)
209
- return r.ok ? 0 : 1
210
- }
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
+ import { relayFetch } from './hive-transport.js' // through the proxy, as every call to the relay
26
+
27
+ export const keysDir = () => process.env.HIVE_KEYS_DIR || path.join(os.homedir(), '.hivecode', 'keys')
28
+ const keyFile = (room) => path.join(keysDir(), `${room}.json`)
29
+ const DAY = 86400
30
+ const nowSec = () => Math.floor(Date.now() / 1000)
31
+
32
+ /** A new personal room, in memory only: nothing is written until the join is allowed to go ahead. */
33
+ export function newPersonalRoom() {
34
+ const kp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
35
+ const publicKey = kp.publicKey.export({ type: 'spki', format: 'pem' })
36
+ const privateKey = kp.privateKey.export({ type: 'pkcs8', format: 'pem' })
37
+ return { room: makeSecuredRoomId(publicKey, crypto.randomBytes(6).toString('hex')), publicKey, privateKey }
38
+ }
39
+
40
+ export function saveRoomKey(k, { relay, host }) {
41
+ fs.mkdirSync(keysDir(), { recursive: true, mode: 0o700 })
42
+ const rec = { room: k.room, relay, host, publicKey: k.publicKey, privateKey: k.privateKey, created: Date.now(), invites: [] }
43
+ // 'wx': a fresh room never overwrites a key that exists, which would orphan the room it opened.
44
+ fs.writeFileSync(keyFile(k.room), JSON.stringify(rec, null, 2), { mode: 0o600, flag: 'wx' })
45
+ return rec
46
+ }
47
+
48
+ /** The key record for a personal room this machine hosts, or null. A file whose key does not match
49
+ * the room's fingerprint is treated as absent: it would only mint tokens the relay refuses. */
50
+ export function loadRoomKey(room) {
51
+ if (!isSecuredRoom(room)) return null
52
+ try {
53
+ const rec = JSON.parse(fs.readFileSync(keyFile(room), 'utf8'))
54
+ return rec && rec.privateKey && keyFingerprint(rec.publicKey) === roomFingerprint(room) ? rec : null
55
+ } catch { return null }
56
+ }
57
+ const writeRec = (rec) => fs.writeFileSync(keyFile(rec.room), JSON.stringify(rec, null, 2), { mode: 0o600 })
58
+
59
+ /** The same claims the extension mints (extension.js mintToken), so either can host and the relay
60
+ * cannot tell them apart. paths = what they may SEE; writePaths = the part of it they may EDIT. */
61
+ export function mintRoomToken(rec, { name, kind = 'ai', role = 'writer', paths, writePaths, owner, ttlSec = 7 * DAY }) {
62
+ const now = nowSec()
63
+ const jti = 'jti-' + crypto.randomBytes(9).toString('base64url')
64
+ const payload = {
65
+ iss: 'hivecode', sub: name, name, kind, ...(owner ? { owner } : {}),
66
+ pk: rec.publicKey, // self-certifying: the relay binds this key to the room id
67
+ scopes: [{ room: rec.room, role, ...(paths && paths.length ? { paths } : {}), ...(writePaths && writePaths.length ? { writePaths } : {}) }],
68
+ iat: now, exp: now + ttlSec, jti,
69
+ }
70
+ return { token: sign(payload, { privateKey: rec.privateKey }), jti, exp: payload.exp }
71
+ }
72
+
73
+ /** The host's own token, minted afresh when it is missing or within an hour of running out: the host
74
+ * holds the key, so its own access must never lapse. Only for the name the room was hosted under --
75
+ * another participant on this machine, joining with its own invite, keeps its own narrower grant. */
76
+ export function hostToken(room, name, current, { kind = 'ai', owner = '' } = {}) {
77
+ const rec = loadRoomKey(room)
78
+ if (!rec || rec.host !== name) return current
79
+ const p = current ? decodeUnsafe(current) : null
80
+ const fresh = !!(p && p.sub === name && p.exp > nowSec() + 3600 && p.pk && keyFingerprint(p.pk) === roomFingerprint(room))
81
+ return fresh ? current : mintRoomToken(rec, { name, kind, role: 'maintainer', owner, ttlSec: 30 * DAY }).token
82
+ }
83
+
84
+ // A path a token may name: relative, no "..", no drive or root. Globs are fine ("src/**", "!**/*.env").
85
+ function badPath(g) {
86
+ const s = String(g).replace(/^!/, '')
87
+ return !s || s.length > 200 || /^([A-Za-z]:|[\\/])/.test(s) || /(^|[\\/])\.\.([\\/]|$)/.test(s) || /[\x00-\x1f]/.test(s)
88
+ }
89
+ const list = (v) => (v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean))
90
+ const when = (sec) => new Date(sec * 1000).toISOString().slice(0, 10)
91
+
92
+ function whatTheyGet(i) {
93
+ const see = i.paths && i.paths.length ? i.paths.join(', ') : 'every file in the room'
94
+ const edit = i.role === 'reader' ? 'read only' : i.writePaths && i.writePaths.length ? `may edit ${i.writePaths.join(', ')}` : 'may edit what they see'
95
+ return `${i.kind}, ${i.role}: sees ${see}; ${edit}`
96
+ }
97
+
98
+ /** Make one person's invite. Returns { ok, text, link?, jti? }. */
99
+ export function makeInvite({ room, relay, name, kind = 'ai', reader = false, paths = [], writePaths = [], days = 7, owner = '' }) {
100
+ const rec = loadRoomKey(room)
101
+ if (!rec) {
102
+ return { ok: false, text: isSecuredRoom(room)
103
+ ? `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.`
104
+ : `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.` }
105
+ }
106
+ const nc = validateMemberName(name)
107
+ if (!nc.ok) return { ok: false, text: `${nc.error}\nNothing was minted.` }
108
+ if (name === rec.host) return { ok: false, text: `"${name}" is the host of this room. Pick the invitee's own name.\nNothing was minted.` }
109
+ const bad = [...paths, ...writePaths].filter(badPath)
110
+ 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.` }
111
+ const d = Number(days)
112
+ 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.` }
113
+ const k = kind === 'human' ? 'human' : 'ai'
114
+ const role = reader ? 'reader' : 'writer'
115
+ const { token, jti, exp } = mintRoomToken(rec, { name, kind: k, role, paths, writePaths, owner, ttlSec: Math.round(d * DAY) })
116
+ const inv = { jti, name, kind: k, role, paths: paths.length ? paths : null, writePaths: writePaths.length ? writePaths : null, exp, at: Date.now() }
117
+ rec.invites = [...(rec.invites || []), inv]
118
+ writeRec(rec)
119
+ const link = `${relay || rec.relay}|${room}|${token}`
120
+ const text = `INVITE for ${name} (${whatTheyGet(inv)}), until ${when(exp)}.
121
+ Send it to ${name} directly, never in the room chat: whoever holds this link joins as ${name}.
122
+
123
+ Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:
124
+ npx -y hivecode-mcp@latest setup "${link}"
125
+
126
+ Take it back any time: npx -y hivecode-mcp@latest revoke ${name}`
127
+ return { ok: true, text, link, jti }
128
+ }
129
+
130
+ const httpBase = (relay) => String(relay).replace(/^ws(s?):\/\//i, 'http$1://').replace(/\/+$/, '')
131
+
132
+ /** Take back every live invite for a name. The relay refuses those tokens from then on and closes any
133
+ * connection using them. Returns { ok, text }. */
134
+ export async function revokeInvite({ room, relay, name }) {
135
+ const rec = loadRoomKey(room)
136
+ if (!rec) return { ok: false, text: `This machine does not host room ${room}, so it cannot take invites back. Only the host can.` }
137
+ const live = (rec.invites || []).filter((i) => i.name === name && !i.revokedAt)
138
+ if (!live.length) {
139
+ const names = [...new Set((rec.invites || []).filter((i) => !i.revokedAt).map((i) => i.name))]
140
+ return { ok: false, text: `No live invite for "${name}" in this room.${names.length ? ` Invited: ${names.join(', ')}.` : ' Nobody has been invited yet.'}` }
141
+ }
142
+ const owner = mintRoomToken(rec, { name: rec.host, role: 'maintainer', ttlSec: 600 }).token
143
+ let done = 0, dropped = 0
144
+ const failures = []
145
+ for (const i of live) {
146
+ try {
147
+ const r = await relayFetch(`${httpBase(relay || rec.relay)}/__hive/revoke`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, token: owner, jti: i.jti }) })
148
+ const body = await r.json().catch(() => ({}))
149
+ if (r.ok && body.ok) { i.revokedAt = Date.now(); done++; dropped += Number(body.dropped) || 0 }
150
+ else failures.push(`HTTP ${r.status}${body.error ? ` ${body.error}` : ''}`)
151
+ } catch (e) { failures.push(e.message) }
152
+ }
153
+ writeRec(rec)
154
+ if (!done) return { ok: false, text: `Could not reach the relay to revoke ${name}'s invite (${failures.join('; ')}). Nothing changed; try again.` }
155
+ 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.` : ''}` }
156
+ }
157
+
158
+ // ---- the command line: hivecode-mcp invite <name> ... | hivecode-mcp revoke <name> ----
159
+
160
+ function parseArgs(argv) {
161
+ const o = { _: [] }
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const a = argv[i]
164
+ if (!a.startsWith('--')) { o._.push(a); continue }
165
+ const key = a.slice(2)
166
+ if (['reader', 'human'].includes(key)) o[key] = true
167
+ else o[key] = argv[++i]
168
+ }
169
+ return o
170
+ }
171
+
172
+ // Which room: --dir's .hive.json, else this folder's, else the only room this machine hosts.
173
+ function findRoom(o) {
174
+ const dir = path.resolve(o.dir || '.')
175
+ try {
176
+ const c = JSON.parse(fs.readFileSync(path.join(dir, '.hive.json'), 'utf8'))
177
+ if (c && c.room) return { room: c.room, relay: c.relay || '' }
178
+ } catch { /* not a room folder */ }
179
+ let mine = []
180
+ try { mine = fs.readdirSync(keysDir()).filter((f) => f.endsWith('.json')).map((f) => loadRoomKey(f.slice(0, -5))).filter(Boolean) } catch { /* no keys yet */ }
181
+ if (mine.length === 1) return { room: mine[0].room, relay: mine[0].relay }
182
+ return { error: mine.length
183
+ ? `${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')}`
184
+ : `${dir} is not a room folder, and this machine hosts no personal room. Host one first: hive_join with private: true.` }
185
+ }
186
+
187
+ const USAGE = `Usage:
188
+ hivecode-mcp invite <name> [--reader] [--human] [--paths a,b] [--edit a,b] [--days 7] [--owner <their human>] [--dir <room folder>]
189
+ hivecode-mcp revoke <name> [--dir <room folder>]
190
+
191
+ --reader they can read but not edit --paths only these folders/globs are visible to them
192
+ --human the invitee is a person, not AI --edit of what they see, only these may be edited
193
+ --days how long the invite lasts (default 7, at most 30)`
194
+
195
+ export async function main(argv) {
196
+ const cmd = argv[0]
197
+ const o = parseArgs(argv.slice(1))
198
+ const name = o._[0]
199
+ if (!name) { console.log(USAGE); return 2 }
200
+ const where = findRoom(o)
201
+ if (where.error) { console.log(where.error); return 1 }
202
+ if (cmd === 'revoke') {
203
+ const r = await revokeInvite({ room: where.room, relay: where.relay, name })
204
+ console.log(r.text)
205
+ return r.ok ? 0 : 1
206
+ }
207
+ 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 || '' })
208
+ console.log(r.text)
209
+ return r.ok ? 0 : 1
210
+ }