hivecode-mcp 0.12.4 → 0.13.0

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.
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ // hive-session.js -- which agent this is, on disk, for the folder its app started it in.
2
+ //
3
+ // The MCP server records its session in <launch folder>/.hive-mcp-session.json, so a restart rejoins
4
+ // as the same agent; the Stop hook, which runs in the same launch folder, reads it to learn which
5
+ // agent it is guarding.
6
+ //
7
+ // WHY THIS IS A MODULE. Until 9 Oct 2026 that file held ONE agent. Two agents started from one folder
8
+ // -- two Claude Code sessions in one repo, the obvious way to run a branch room -- overwrote each
9
+ // other, and the last writer was the only agent on record. The other one's Stop hook then steered it
10
+ // as the wrong agent (that agent's mail, a listener under that agent's name), and a restart brought
11
+ // it back as the other one. Found by walking the founder flow end to end (branch -> two agents ->
12
+ // pr), where the file also showed up in the developer's `git status`.
13
+ //
14
+ // Now the file keeps every agent started in that folder, each with the pid of the server that wrote
15
+ // it. Its top level is still the latest writer, which is all a Stop hook from before this reads, so an
16
+ // older hook keeps working exactly as it did. Server and hook read it through this one module, so
17
+ // the two cannot drift apart (the merge logic once lived twice, and the copies did).
18
+ import fs from 'fs'
19
+ import path from 'path'
20
+ import { spawnSync } from 'child_process'
21
+
22
+ export const SESSION_FILE_NAME = '.hive-mcp-session.json'
23
+ export const HOOK_STATE_NAME = '.hive-stop-hook.json'
24
+ const STALE_MS = 14 * 24 * 3600 * 1000 // a record whose server is gone and that nobody wrote for two weeks
25
+
26
+ export function pidAlive(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0) return false
28
+ try { process.kill(pid, 0); return true } catch (e) { return !!e && e.code === 'EPERM' }
29
+ }
30
+
31
+ /** Every agent on record in a session file, by name. A file from before 9 Oct holds one, at its top level. */
32
+ export function readAgents(file) {
33
+ let raw
34
+ try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return {} }
35
+ if (!raw || typeof raw !== 'object') return {}
36
+ if (raw.agents && typeof raw.agents === 'object') return { ...raw.agents }
37
+ if (raw.name) { const one = { ...raw }; delete one.agents; return { [raw.name]: one } }
38
+ return {}
39
+ }
40
+
41
+ /** Write the records back; the top level is `latest` (or the newest record), for a Stop hook from before 9 Oct. */
42
+ export function writeAgents(file, agents, latest = null) {
43
+ const now = Date.now()
44
+ for (const [n, s] of Object.entries(agents)) if (!pidAlive(s.pid) && s.at && now - s.at > STALE_MS) delete agents[n]
45
+ const names = Object.keys(agents)
46
+ if (!names.length) { try { fs.rmSync(file) } catch { } return }
47
+ const top = latest && agents[latest.name] === latest ? latest
48
+ : agents[names.sort((a, b) => (agents[b].at || 0) - (agents[a].at || 0))[0]]
49
+ fs.writeFileSync(file, JSON.stringify({ ...top, agents }))
50
+ }
51
+
52
+ /** The record this server process owns: the one it wrote; else the one for its conversation; else, after
53
+ * a restart, the only one whose server is gone. Two gone is a guess, and a guess here is exactly how one
54
+ * agent wakes up as another, so it answers null and the agent rejoins by name instead. */
55
+ export function ownRecord(agents, { pid = process.pid, conv = '' } = {}) {
56
+ const all = Object.values(agents)
57
+ const mine = all.find((s) => s.pid === pid) || (conv ? all.find((s) => s.conv === conv) : null)
58
+ if (mine) return mine
59
+ const gone = all.filter((s) => !pidAlive(s.pid))
60
+ return gone.length === 1 ? gone[0] : null
61
+ }
62
+
63
+ /** Which agent a Stop hook is guarding. One on record is that one. With several, the hook's own
64
+ * conversation decides: its id, else its transcript (the hive_join that answered "folder: <dir>",
65
+ * then that folder anywhere in it), else the only one whose server still runs. If none of that
66
+ * tells, null: a hook that speaks as the wrong agent does more harm than one that stays quiet. */
67
+ export function agentForHook(agents, hook = {}, { readTail = tailOf } = {}) {
68
+ const all = Object.values(agents).filter((s) => s && s.dir && s.name)
69
+ if (all.length <= 1) return all[0] || null
70
+ const conv = String(hook.session_id || hook.conversationId || '')
71
+ if (conv) { const byConv = all.find((s) => s.conv && s.conv === conv); if (byConv) return byConv }
72
+ const t = hook.transcript_path ? readTail(hook.transcript_path) : ''
73
+ if (t) {
74
+ // The folder as a transcript line spells it: escaped once as JSON, twice if the text was nested.
75
+ const spell = (dir) => { const once = JSON.stringify(String(dir)).slice(1, -1); return [once, JSON.stringify(once).slice(1, -1)] }
76
+ const where = (s) => {
77
+ const forms = spell(s.dir)
78
+ const joined = Math.max(...forms.map((f) => t.lastIndexOf('folder: ' + f)))
79
+ return joined >= 0 ? [1, joined] : [0, Math.max(...forms.map((f) => t.lastIndexOf(f)))]
80
+ }
81
+ const ranked = all.map((s) => ({ s, w: where(s) })).filter((x) => x.w[1] >= 0)
82
+ .sort((a, b) => b.w[0] - a.w[0] || b.w[1] - a.w[1])
83
+ if (ranked.length) return ranked[0].s
84
+ }
85
+ const live = all.filter((s) => pidAlive(s.pid))
86
+ return live.length === 1 ? live[0] : null
87
+ }
88
+
89
+ function tailOf(file, max = 8 * 1024 * 1024) {
90
+ try {
91
+ const size = fs.statSync(file).size
92
+ const n = Math.min(size, max)
93
+ const fd = fs.openSync(file, 'r')
94
+ try { const b = Buffer.alloc(n); fs.readSync(fd, b, 0, n, size - n); return b.toString('utf8') } finally { fs.closeSync(fd) }
95
+ } catch { return '' }
96
+ }
97
+
98
+ /** Keep our files out of `git status` in the launch folder, which is often the developer's own repo:
99
+ * listed in that repo's local exclude file (.git/info/exclude), never in a tracked .gitignore. */
100
+ export function excludeFromGit(dir, names = [SESSION_FILE_NAME, HOOK_STATE_NAME]) {
101
+ try {
102
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: dir, encoding: 'utf8', windowsHide: true, timeout: 5000 })
103
+ if (r.status !== 0 || !String(r.stdout || '').trim()) return false
104
+ const file = path.resolve(dir, String(r.stdout).trim())
105
+ let have = ''
106
+ try { have = fs.readFileSync(file, 'utf8') } catch { }
107
+ const listed = new Set(have.split(/\r?\n/).map((l) => l.trim()))
108
+ const add = names.filter((n) => !listed.has(n) && !listed.has('/' + n))
109
+ if (!add.length) return true
110
+ fs.mkdirSync(path.dirname(file), { recursive: true })
111
+ fs.appendFileSync(file, `${have && !have.endsWith('\n') ? '\n' : ''}# Hivecode's local session files\n${add.join('\n')}\n`)
112
+ return true
113
+ } catch { return false }
114
+ }
package/hive-setup.js CHANGED
@@ -259,13 +259,14 @@ export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
259
259
  * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
260
260
  * npm prunes it and the config would break months later with nothing to connect it to setup.
261
261
  */
262
- export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
262
+ export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url), { log } = {}) {
263
263
  const pkgDir = path.dirname(here)
264
264
  const nm = path.dirname(pkgDir)
265
265
  if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
266
266
  const dest = path.join(home, '.hivecode', 'runtime', VERSION)
267
267
  const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
268
268
  if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
269
+ if (log) log(` installing runtime under ${dest} (first run can take ~25s on Windows)...`)
269
270
  // ONLY the dependency closure is copied, never the whole node_modules around us: that would
270
271
  // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
271
272
  const closure = new Set()
@@ -320,7 +321,14 @@ export function projectRepo(cwd = process.cwd(), home = os.homedir()) {
320
321
  }
321
322
  const top = git('rev-parse', '--show-toplevel')
322
323
  if (!top || !git('rev-parse', '--verify', '-q', 'HEAD^{commit}')) return null
323
- const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p))
324
+ // THROUGH LINKS (6 Oct 2026, the first macOS run). git names the top folder with links resolved,
325
+ // and the home folder comes as written. macOS temp folders sit behind one (/var is a link to
326
+ // /private/var), so a home kept in git was offered as a project there (F7). Both sides are resolved.
327
+ const norm = (p) => {
328
+ let r = path.resolve(p)
329
+ try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
330
+ return process.platform === 'win32' ? r.toLowerCase() : r
331
+ }
324
332
  return norm(top) === norm(home) ? null : path.resolve(top)
325
333
  }
326
334
 
@@ -372,7 +380,7 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
372
380
  if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
373
381
 
374
382
  let runtime
375
- try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
383
+ try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home, undefined, { log }) }
376
384
  catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
377
385
  log(` runtime ${runtime.js}`)
378
386
  log(` node ${process.execPath}\n`)
@@ -400,7 +408,8 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
400
408
  // A project's own .mcp.json outranks everything written above, inside that project. It is a
401
409
  // shared, committed file, so it is reported rather than rewritten with this machine's paths.
402
410
  const projectMcp = path.join(process.cwd(), '.mcp.json')
403
- if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
411
+ // Removing the entry only here did not last on 9 Oct 2026: the file was committed, and a checkout put it back.
412
+ if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry. If the file is committed, remove it in the repository: a copy changed only here comes back with the next checkout or pull.\n`)
404
413
 
405
414
  let failed = 0
406
415
  const ready = []
@@ -539,6 +548,7 @@ export async function main(argv = [], { home = os.homedir(), log = console.log }
539
548
  }
540
549
  }
541
550
  log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
551
+ log(` Two agents on one machine: open two Claude Code terminals, or pair two apps (GUI editors share one MCP process across tabs).`)
542
552
  // With a link, the room itself: its relay's Control Room with the link in the hash, so a person
543
553
  // never pastes wss:// into an address bar. Without one, the panel they paste a link into.
544
554
  const panel = (link && controlUrl(link)) || `${(process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).replace(/^ws/, 'http').replace(/\/+$/, '')}/control`