hivecode-mcp 0.14.1 → 0.14.3

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 +106 -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 +674 -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 +709 -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 +92 -12
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +354 -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 +637 -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 +1761 -1668
  41. package/hive-room-watcher.js +623 -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 +2417 -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 +617 -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 +856 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +695 -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 +11 -3
  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 +434 -60
  72. package/token.js +201 -201
package/hive-setup.js CHANGED
@@ -1,560 +1,560 @@
1
- // hive-setup.js -- `npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI]`
2
- //
3
- // ONE COMMAND FROM NOTHING TO AN AGENT THAT CAN JOIN. It exists because setup cost a real
4
- // tester an hour on 24 Sep 2026, and when it was measured the next day on a cold machine the
5
- // hour had concrete causes, none of them in the relay (hive_join itself took 0.5s):
6
- //
7
- // 1. The README's config was {"command":"npx", ...}. Agent apps start MCP servers WITHOUT a
8
- // shell, and on Windows npx is npx.cmd, so the launch fails with `spawn npx ENOENT` -- the
9
- // agent just never gets the hive_* tools, and nothing on screen says why.
10
- // 2. The first `npx hivecode-mcp@latest` start took 36.7s, downloading. Claude Code gives an
11
- // MCP server 30s, so a new user's FIRST launch fails, and the second one may not.
12
- // 3. hive_join's default folder was './workspace', relative to whatever directory the agent
13
- // app happened to start the server in -- sometimes the user's project, sometimes a
14
- // directory nobody can write to.
15
- //
16
- // So this does the four things a person was doing by hand, and then CHECKS them:
17
- // - installs a runtime copy under ~/.hivecode/runtime/<version>, so launching needs no
18
- // download, no network and no npx;
19
- // - writes the hivecode entry into every agent config it finds, with the absolute path of
20
- // THIS node, which also dodges GUI apps on macOS not seeing an nvm PATH;
21
- // - launches each entry exactly the way the agent app will -- read back from the file, no
22
- // shell -- and does a real MCP handshake with it;
23
- // - prints the one sentence to paste into the agent.
24
- //
25
- // THE FAILURE THIS MUST NEVER HAVE is the one every defect in this repo has had: reporting
26
- // success while the result is wrong. An agent whose config could not be written, or whose entry
27
- // does not start, is printed as FAILED and makes the exit code non-zero. "Configured" is only
28
- // ever printed about an entry that was read back from disk and answered a handshake.
29
- //
30
- // WHAT IT COSTS WHEN WRONG: these are the user's own config files, holding their other MCP
31
- // servers. So a file is parsed or refused (JSON with comments is refused, not rewritten), the
32
- // original is backed up once before the first change, only the `hivecode` key is touched, and
33
- // every other key survives byte-for-byte as a value.
34
- import fs from 'fs'
35
- import os from 'os'
36
- import path from 'path'
37
- import { spawn, spawnSync } from 'child_process'
38
- import { fileURLToPath } from 'url'
39
- import { VERSION } from './hive-version.js'
40
- import { currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
41
- import { validateMemberName } from './hive-mailbox.js'
42
- import { installStopHook, installAntigravityStopHook } from './hive-hook-install.js'
43
- import { relayFetch } from './hive-transport.js' // /health through the proxy, as every call to the relay
44
-
45
- export const SERVER_KEY = 'hivecode'
46
- const BACKUP_SUFFIX = '.before-hivecode'
47
-
48
- /** Every agent app we know how to configure, and where each keeps its MCP servers. */
49
- export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
50
- const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
51
- : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
52
- : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
53
- // `present` is how we decide the app is installed. It is deliberately the app's OWN directory
54
- // (or, where the app creates it on first run, its own file) -- never "the file we would write",
55
- // which would make every run create configs for apps the user does not have.
56
- return [
57
- { id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
58
- restart: 'start a new Claude Code session (or restart `claude`)' },
59
- { id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
60
- restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
61
- // Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
62
- // a stale one there could shadow the fresh one above -- and never created.
63
- { id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
64
- onlyIfHasEntry: true, secondaryOf: 'antigravity', restart: 'same refresh as above' },
65
- { id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
66
- restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
67
- { id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
68
- restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
69
- { id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
70
- restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
71
- // The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
72
- // settings file does: the CLI writes it on first run.
73
- { id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
74
- restart: 'restart `gemini`' },
75
- ]
76
- }
77
-
78
- /**
79
- * The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
80
- * started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
81
- * absolute path that is still this same program. process.execPath on Homebrew is the versioned
82
- * Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
83
- * the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
84
- * can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
85
- */
86
- export function stableNodePath(execPath = process.execPath, env = process.env) {
87
- const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
88
- const target = real(execPath)
89
- if (!target) return execPath
90
- const exe = process.platform === 'win32' ? 'node.exe' : 'node'
91
- const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
92
- const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
93
- const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
94
- for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
95
- return execPath
96
- }
97
-
98
- /**
99
- * Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
100
- * trailing commas, all outside strings. Returns the plain-JSON text.
101
- */
102
- export function stripJsonc(src) {
103
- let out = ''
104
- for (let i = 0, inStr = false; i < src.length; i++) {
105
- const c = src[i]
106
- if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
107
- if (c === '"') { inStr = true; out += c; continue }
108
- if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
109
- if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
110
- out += c
111
- }
112
- let res = ''
113
- for (let i = 0, inStr = false; i < out.length; i++) {
114
- const c = out[i]
115
- if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
116
- if (c === '"') { inStr = true; res += c; continue }
117
- if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
118
- res += c
119
- }
120
- return res
121
- }
122
-
123
- /** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
124
- export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
125
- const entry = { command: node, args: [runtimeJs] }
126
- // What the user added to an existing entry is theirs: a token, a relay, a working directory.
127
- // Only the launch itself -- command and args -- is ours to replace.
128
- if (previous && typeof previous === 'object') {
129
- if (previous.env && typeof previous.env === 'object') entry.env = previous.env
130
- if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
131
- }
132
- if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
133
- return entry
134
- }
135
-
136
- /**
137
- * Write the hivecode entry into one config file. Returns { status, detail }, where status is one
138
- * of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
139
- */
140
- export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
141
- let raw = ''
142
- try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
143
- let cfg = {}
144
- let droppedComments = false
145
- if (raw.trim()) {
146
- const text = raw.replace(/^/, '')
147
- try { cfg = JSON.parse(text) } catch {
148
- // JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
149
- // the user back the hand-editing this command exists to remove; the original is kept.
150
- try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
151
- return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
152
- }
153
- }
154
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
155
- }
156
- const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
157
- const previous = servers[SERVER_KEY] || null
158
- if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
159
- const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
160
- const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
161
- // CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
162
- // told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
163
- // followed the old README therefore has a broken npx entry that would silently win over the one
164
- // written above -- and this command would have printed READY about it. Those are ours by name.
165
- let projectFixes = 0
166
- if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
167
- for (const [p, v] of Object.entries(cfg.projects)) {
168
- const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
169
- if (!old || typeof old !== 'object') continue
170
- const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
171
- if (JSON.stringify(fixed) === JSON.stringify(old)) continue
172
- if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
173
- next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
174
- projectFixes++
175
- }
176
- }
177
- if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
178
- if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
179
- try {
180
- fs.mkdirSync(path.dirname(target.file), { recursive: true })
181
- // The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
182
- // second or tenth run can never overwrite the one copy worth going back to.
183
- if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
184
- const tmp = target.file + '.hivecode-tmp'
185
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
186
- try { fs.renameSync(tmp, target.file) } catch {
187
- // A locked destination (an app holding it, an antivirus scan) refuses the rename on
188
- // Windows. Writing in place is less atomic but still leaves a whole file behind.
189
- fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
190
- }
191
- } catch (e) { return { status: 'error', detail: e.message } }
192
- let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
193
- if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
194
- if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
195
- return { status: 'written', detail }
196
- }
197
-
198
- /**
199
- * Every hivecode entry in a written file must launch the same thing -- including Claude Code's
200
- * per-folder ones, which are never verified by launching separately. Returns the first entry
201
- * that disagrees with `entry`, or null.
202
- */
203
- export function strayEntry(file, entry) {
204
- let cfg
205
- try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
206
- for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
207
- const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
208
- if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
209
- }
210
- return null
211
- }
212
-
213
- /** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
214
- export function readEntry(file) {
215
- try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
216
- }
217
-
218
- /**
219
- * Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
220
- * complete a real `initialize` handshake. Anything short of a hivecode server answering is a
221
- * failure with the reason attached.
222
- */
223
- export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
224
- return new Promise((resolve) => {
225
- if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
226
- const t0 = Date.now()
227
- const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
228
- let child
229
- try {
230
- child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
231
- } catch (e) { return resolve({ ok: false, error: e.message }) }
232
- let out = ''
233
- let errTail = ''
234
- let settled = false
235
- const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
236
- const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
237
- child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
238
- child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
239
- child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
240
- child.stdout.on('data', (d) => {
241
- out += d
242
- let i
243
- while ((i = out.indexOf('\n')) >= 0) {
244
- const line = out.slice(0, i); out = out.slice(i + 1)
245
- let m; try { m = JSON.parse(line) } catch { continue }
246
- if (m.id !== 1) continue
247
- const info = m.result && m.result.serverInfo
248
- if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
249
- else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
250
- }
251
- })
252
- child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'hivecode-setup', version: VERSION } } }) + '\n')
253
- })
254
- }
255
-
256
- /**
257
- * Put a runtime where agent apps can launch it with no download. When this file is running out
258
- * of a checkout (development), the checkout IS the runtime. When it is running out of an
259
- * installed package (npx), the package and its dependency closure are copied under
260
- * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
261
- * npm prunes it and the config would break months later with nothing to connect it to setup.
262
- */
263
- export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url), { log } = {}) {
264
- const pkgDir = path.dirname(here)
265
- const nm = path.dirname(pkgDir)
266
- if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
267
- const dest = path.join(home, '.hivecode', 'runtime', VERSION)
268
- const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
269
- if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
270
- if (log) log(` installing runtime under ${dest} (first run can take ~25s on Windows)...`)
271
- // ONLY the dependency closure is copied, never the whole node_modules around us: that would
272
- // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
273
- const closure = new Set()
274
- const seen = new Set()
275
- const visit = (name, fromDir) => {
276
- // Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
277
- // requiring package, skipping directories that ARE node_modules (Node never looks in
278
- // node_modules/node_modules), and stopping at the folder that holds our node_modules.
279
- for (let d = fromDir; ;) {
280
- if (path.basename(d) !== 'node_modules') {
281
- const cand = path.join(d, 'node_modules', name)
282
- if (fs.existsSync(path.join(cand, 'package.json'))) {
283
- if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
284
- seen.add(cand)
285
- // Nested copies travel inside their parent's directory; only top-level ones are listed.
286
- if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
287
- let pj = {}
288
- try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
289
- for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
290
- return
291
- }
292
- }
293
- const up = path.dirname(d)
294
- if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
295
- d = up
296
- }
297
- }
298
- closure.add('hivecode-mcp')
299
- let rootPj = {}
300
- try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
301
- for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
302
- const tmp = `${dest}.tmp-${process.pid}`
303
- fs.rmSync(tmp, { recursive: true, force: true })
304
- for (const name of closure) {
305
- fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
306
- }
307
- fs.rmSync(dest, { recursive: true, force: true })
308
- fs.renameSync(tmp, dest)
309
- return { mode: 'installed', js, dir: dest, packages: closure.size }
310
- }
311
-
312
- /** The project this was run in, when there is one to offer: a git work tree with at least one
313
- * commit, whose top is not the home folder (a home kept in git is a dotfiles repo, not a project;
314
- * this machine's is one). Returns the repo's top folder, or null. Any git failure is null: setup
315
- * must never fail because git is missing. */
316
- export function projectRepo(cwd = process.cwd(), home = os.homedir()) {
317
- const git = (...args) => {
318
- try {
319
- const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 10000, windowsHide: true })
320
- return r.status === 0 ? String(r.stdout || '').trim() : ''
321
- } catch { return '' }
322
- }
323
- const top = git('rev-parse', '--show-toplevel')
324
- if (!top || !git('rev-parse', '--verify', '-q', 'HEAD^{commit}')) return null
325
- // THROUGH LINKS (6 Oct 2026, the first macOS run). git names the top folder with links resolved,
326
- // and the home folder comes as written. macOS temp folders sit behind one (/var is a link to
327
- // /private/var), so a home kept in git was offered as a project there (F7). Both sides are resolved.
328
- const norm = (p) => {
329
- let r = path.resolve(p)
330
- try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
331
- return process.platform === 'win32' ? r.toLowerCase() : r
332
- }
333
- return norm(top) === norm(home) ? null : path.resolve(top)
334
- }
335
-
336
- function defaultName() {
337
- let base = 'agent'
338
- try { base = os.userInfo().username || base } catch {}
339
- const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
340
- const v = validateMemberName(cand)
341
- return v.ok ? cand : v.suggested
342
- }
343
-
344
- /** Exported for tests/proxy-fetch-test.mjs. */
345
- export async function wakeRelay(relay) {
346
- const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
347
- const t0 = Date.now()
348
- try {
349
- const r = await relayFetch(url, { signal: AbortSignal.timeout(75000) })
350
- return { ok: r.ok, ms: Date.now() - t0, status: r.status }
351
- } catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
352
- }
353
-
354
- function parseArgs(argv) {
355
- const o = { link: '', name: '', dryRun: false, help: false }
356
- for (let i = 0; i < argv.length; i++) {
357
- const a = argv[i]
358
- if (a === '--name') o.name = argv[++i] || ''
359
- else if (a.startsWith('--name=')) o.name = a.slice(7)
360
- else if (a === '--dry-run') o.dryRun = true
361
- else if (a === '-h' || a === '--help') o.help = true
362
- else if (!a.startsWith('-') && !o.link) o.link = a
363
- }
364
- return o
365
- }
366
-
367
- const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
368
-
369
- /** Returns the process exit code: 0 only when nothing that exists failed. */
370
- export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
371
- const o = parseArgs(argv)
372
- if (o.help) {
373
- log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
374
- return 0
375
- }
376
- log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
377
- const major = Number(process.versions.node.split('.')[0])
378
- if (major < 18) { log(` FAILED Node ${process.versions.node} is too old. Install Node 18 or newer from https://nodejs.org and run this again.`); return 1 }
379
-
380
- let name = o.name || defaultName()
381
- const nv = validateMemberName(name)
382
- if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
383
-
384
- let runtime
385
- 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 }) }
386
- catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
387
- log(` runtime ${runtime.js}`)
388
- log(` node ${process.execPath}\n`)
389
-
390
- const results = []
391
- for (const t of agentTargets(home)) {
392
- // A second config path of an app that IS installed is not an app of its own: printing
393
- // "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
394
- if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
395
- const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
396
- if (w.status !== 'skipped') results.push({ t, ...w })
397
- }
398
- // Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
399
- const cache = new Map()
400
- for (const r of results) {
401
- if (r.status !== 'written' && r.status !== 'unchanged') continue
402
- if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
403
- const entry = readEntry(r.t.file)
404
- const key = JSON.stringify(entry)
405
- if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
406
- r.verify = cache.get(key)
407
- const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
408
- if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
409
- }
410
- // A project's own .mcp.json outranks everything written above, inside that project. It is a
411
- // shared, committed file, so it is reported rather than rewritten with this machine's paths.
412
- const projectMcp = path.join(process.cwd(), '.mcp.json')
413
- // Removing the entry only here did not last on 9 Oct 2026: the file was committed, and a checkout put it back.
414
- 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`)
415
-
416
- let failed = 0
417
- const ready = []
418
- // A second config file of an app is not an app of its own. A READY one folds into its app's line
419
- // ("Antigravity (2nd) READY" under "Antigravity READY" made a stranger ask which one was real);
420
- // a FAILED one is never folded -- it keeps a line of its own.
421
- const isReady = (r) => (r.status === 'written' || r.status === 'unchanged') && r.verify && r.verify.ok
422
- const folded = new Set(results.filter((r) => r.t.secondaryOf && isReady(r) && results.some((p) => p.t.id === r.t.secondaryOf && isReady(p))))
423
- for (const r of results) {
424
- if (folded.has(r)) continue
425
- const where = r.t.file
426
- if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
427
- if (r.status === 'refused' || r.status === 'error') {
428
- failed++
429
- log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
430
- continue
431
- }
432
- if (!r.verify.ok) {
433
- failed++
434
- log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
435
- continue
436
- }
437
- const extra = results.filter((x) => folded.has(x) && x.t.secondaryOf === r.t.id)
438
- r.changed = r.status === 'written' || extra.some((x) => x.status === 'written')
439
- ready.push(r)
440
- const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
441
- const what = r.status === 'written' ? r.detail : r.changed ? 'updated its second config file' : 'already set up'
442
- log(` ${pad(r.t.label, 15)} READY ${what} (${how}${extra.length ? `, ${extra.length + 1} config files` : ''})`)
443
- }
444
-
445
- // THE STOP GATE, FOR CLAUDE CODE (4 Oct 2026). An agent in a room went deaf the moment it ended a
446
- // turn with no listener running, and the gate that stops that (hive-stop-hook.js) was a separate
447
- // command nobody ran. Setup configures the agent apps, so it arms the gate too: in
448
- // ~/.claude/settings.json beside any Stop hook already there, merged, never replaced. Only once
449
- // the hook has been seen to run, and only for a Claude Code setup made READY -- a hook that cannot
450
- // start fails on every turn of every session on the machine.
451
- const cc = ready.find((r) => r.t.claudeCode)
452
- if (cc) {
453
- const node = stableNodePath().replace(/\\/g, '/')
454
- const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
455
- const settings = path.join(home, '.claude', 'settings.json')
456
- // stop_hook_active: the hook loads everything it imports, then lets the stop through untouched.
457
- const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
458
- if (ran.status !== 0) {
459
- log(` ${pad('stop gate', 15)} NOT added: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
460
- } else {
461
- const h = installStopHook(settings, `"${node}" "${hookJs}"`, { dryRun: o.dryRun })
462
- if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added: ${h.detail}. Fix that file, then run setup again.`)
463
- else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${settings}`)
464
- else {
465
- cc.changed = true // a hook loads only when Claude Code starts, so it is on the restart list
466
- log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${settings}: in a room, Claude Code is not let end a turn with no listener running${h.others ? ` (${h.others} other Stop hook${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
467
- }
468
- }
469
- }
470
-
471
- const agy = ready.find((r) => r.t.id === 'antigravity')
472
- if (agy) {
473
- const node = stableNodePath().replace(/\\/g, '/')
474
- const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
475
- const hooksPath = path.join(home, '.gemini', 'config', 'hooks.json')
476
- const cmd = `"${node}" "${hookJs}" --app=antigravity`
477
- const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs, '--app=antigravity'], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
478
- if (ran.status !== 0) {
479
- log(` ${pad('stop gate', 15)} NOT added for Antigravity: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
480
- } else {
481
- const h = installAntigravityStopHook(hooksPath, cmd, { dryRun: o.dryRun })
482
- if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added for Antigravity: ${h.detail}. Fix that file, then run setup again.`)
483
- else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${hooksPath}`)
484
- else {
485
- agy.changed = true
486
- log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${hooksPath}: in a room, Antigravity is not let end a turn with no listener running${h.others ? ` (${h.others} other hook group${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
487
- }
488
- }
489
- }
490
-
491
- const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
492
- if (failed || !ready.length) {
493
- log(`\n ${ready.length ? 'For the agents above that FAILED' : 'No supported agent app was found. For any MCP agent'}, add this to its MCP settings:\n`)
494
- log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
495
- }
496
-
497
- let link = o.link.trim()
498
- if (link) {
499
- const [relayPart, ...rest] = link.split('|')
500
- const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
501
- if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
502
- if (!rest.length || !rest[0].trim()) {
503
- // THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
504
- // one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
505
- // explanation also goes to stderr, the one stream still reaching the terminal.
506
- const hint = `\n WARNING "${o.link}" has no room in it -- the part after | was cut off. Put the whole link in quotes, or leave it out: you give the link to your agent, not to this command.`
507
- log(hint)
508
- if (!process.stdout.isTTY) console.error(hint)
509
- }
510
- const w = await wakeRelay(relay)
511
- log(w.ok ? `\n relay ${relay} is up (answered in ${(w.ms / 1000).toFixed(1)}s)` : `\n WARNING ${relay} did not answer (${w.error || 'HTTP ' + w.status}). Joining will fail until it does.`)
512
- }
513
-
514
- if (ready.length) {
515
- log(`\nNEXT\n`)
516
- // Numbered steps only where this run wrote something. An entry that was already right says
517
- // nothing about whether the RUNNING app has loaded it -- "run setup again, then reload the agent"
518
- // is the README's first fix for a missing hive_join -- so those apps get one line, never silence.
519
- const toRestart = ready.filter((r) => r.changed)
520
- const already = ready.filter((r) => !r.changed)
521
- toRestart.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
522
- if (already.length) log(` Already set up: ${already.map((r) => r.t.label).join(', ')}. Restart ${already.length === 1 ? 'it' : 'them'} only if the hivecode tools are missing there.`)
523
- if (link) {
524
- log(`\n Then paste this to your agent:\n`)
525
- log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
526
- } else {
527
- // THE EMPTY-ROOM TRAP (onboarding audit, 2 Oct 2026). A team runs setup in its project and
528
- // is told to host a room, which is new and empty: its code is not in it, and nothing said how
529
- // to bring it in. `branch` does, so inside a repo it comes first -- with the privacy line
530
- // BEFORE the command, because running it publishes the repo's committed files. The name is
531
- // a real one: `<name>` pasted into PowerShell is a parse error, not a placeholder.
532
- const repo = projectRepo(process.cwd(), home)
533
- if (repo) {
534
- const relay = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY
535
- log(`\n You're in a git repo (${repo}). To give your agents its code, run this here first:\n`)
536
- log(` npx -y hivecode-mcp@latest branch hive-work\n`)
537
- log(` It cuts a new branch, hive-work, without switching your checkout, puts its committed files in a room,`)
538
- log(` and prints the line for your agents and for teammates.${currentRelayFor(relay).relay === PUBLIC_DEFAULT_RELAY ? ' This uses the public relay: anyone with the room\'s link can read those files.' : ''}`)
539
- }
540
- // Inside a repo the empty-room line is the trap itself: Antigravity1's scripted stranger walk
541
- // (3 Oct) found a stranger skips the command above and pastes "host a new, empty room". Only
542
- // the line for someone who was SENT a link stays; the README still documents an empty room.
543
- if (repo) {
544
- log(`\n If you were sent a link instead, paste this to your agent:\n`)
545
- log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
546
- } else {
547
- log(`\n Then paste ONE of these to your agent:\n`)
548
- log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
549
- log(` Use your hive_join tool to host a new, empty Hivecode room as ${name}, then give me the join link.`)
550
- }
551
- }
552
- log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
553
- log(` Two agents on one machine: open two Claude Code terminals, or pair two apps (GUI editors share one MCP process across tabs).`)
554
- // With a link, the room itself: its relay's Control Room with the link in the hash, so a person
555
- // never pastes wss:// into an address bar. Without one, the panel they paste a link into.
556
- const panel = (link && controlUrl(link)) || `${(process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).replace(/^ws/, 'http').replace(/\/+$/, '')}/control`
557
- log(` People join from a browser, no install: ${panel}\n`)
558
- }
559
- return failed ? 1 : 0
560
- }
1
+ // hive-setup.js -- `npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI]`
2
+ //
3
+ // ONE COMMAND FROM NOTHING TO AN AGENT THAT CAN JOIN. It exists because setup cost a real
4
+ // tester an hour on 24 Sep 2026, and when it was measured the next day on a cold machine the
5
+ // hour had concrete causes, none of them in the relay (hive_join itself took 0.5s):
6
+ //
7
+ // 1. The README's config was {"command":"npx", ...}. Agent apps start MCP servers WITHOUT a
8
+ // shell, and on Windows npx is npx.cmd, so the launch fails with `spawn npx ENOENT` -- the
9
+ // agent just never gets the hive_* tools, and nothing on screen says why.
10
+ // 2. The first `npx hivecode-mcp@latest` start took 36.7s, downloading. Claude Code gives an
11
+ // MCP server 30s, so a new user's FIRST launch fails, and the second one may not.
12
+ // 3. hive_join's default folder was './workspace', relative to whatever directory the agent
13
+ // app happened to start the server in -- sometimes the user's project, sometimes a
14
+ // directory nobody can write to.
15
+ //
16
+ // So this does the four things a person was doing by hand, and then CHECKS them:
17
+ // - installs a runtime copy under ~/.hivecode/runtime/<version>, so launching needs no
18
+ // download, no network and no npx;
19
+ // - writes the hivecode entry into every agent config it finds, with the absolute path of
20
+ // THIS node, which also dodges GUI apps on macOS not seeing an nvm PATH;
21
+ // - launches each entry exactly the way the agent app will -- read back from the file, no
22
+ // shell -- and does a real MCP handshake with it;
23
+ // - prints the one sentence to paste into the agent.
24
+ //
25
+ // THE FAILURE THIS MUST NEVER HAVE is the one every defect in this repo has had: reporting
26
+ // success while the result is wrong. An agent whose config could not be written, or whose entry
27
+ // does not start, is printed as FAILED and makes the exit code non-zero. "Configured" is only
28
+ // ever printed about an entry that was read back from disk and answered a handshake.
29
+ //
30
+ // WHAT IT COSTS WHEN WRONG: these are the user's own config files, holding their other MCP
31
+ // servers. So a file is parsed or refused (JSON with comments is refused, not rewritten), the
32
+ // original is backed up once before the first change, only the `hivecode` key is touched, and
33
+ // every other key survives byte-for-byte as a value.
34
+ import fs from 'fs'
35
+ import os from 'os'
36
+ import path from 'path'
37
+ import { spawn, spawnSync } from 'child_process'
38
+ import { fileURLToPath } from 'url'
39
+ import { VERSION } from './hive-version.js'
40
+ import { currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
41
+ import { validateMemberName } from './hive-mailbox.js'
42
+ import { installStopHook, installAntigravityStopHook } from './hive-hook-install.js'
43
+ import { relayFetch } from './hive-transport.js' // /health through the proxy, as every call to the relay
44
+
45
+ export const SERVER_KEY = 'hivecode'
46
+ const BACKUP_SUFFIX = '.before-hivecode'
47
+
48
+ /** Every agent app we know how to configure, and where each keeps its MCP servers. */
49
+ export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
50
+ const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
51
+ : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
52
+ : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
53
+ // `present` is how we decide the app is installed. It is deliberately the app's OWN directory
54
+ // (or, where the app creates it on first run, its own file) -- never "the file we would write",
55
+ // which would make every run create configs for apps the user does not have.
56
+ return [
57
+ { id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
58
+ restart: 'start a new Claude Code session (or restart `claude`)' },
59
+ { id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
60
+ restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
61
+ // Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
62
+ // a stale one there could shadow the fresh one above -- and never created.
63
+ { id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
64
+ onlyIfHasEntry: true, secondaryOf: 'antigravity', restart: 'same refresh as above' },
65
+ { id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
66
+ restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
67
+ { id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
68
+ restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
69
+ { id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
70
+ restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
71
+ // The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
72
+ // settings file does: the CLI writes it on first run.
73
+ { id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
74
+ restart: 'restart `gemini`' },
75
+ ]
76
+ }
77
+
78
+ /**
79
+ * The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
80
+ * started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
81
+ * absolute path that is still this same program. process.execPath on Homebrew is the versioned
82
+ * Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
83
+ * the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
84
+ * can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
85
+ */
86
+ export function stableNodePath(execPath = process.execPath, env = process.env) {
87
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
88
+ const target = real(execPath)
89
+ if (!target) return execPath
90
+ const exe = process.platform === 'win32' ? 'node.exe' : 'node'
91
+ const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
92
+ const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
93
+ const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
94
+ for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
95
+ return execPath
96
+ }
97
+
98
+ /**
99
+ * Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
100
+ * trailing commas, all outside strings. Returns the plain-JSON text.
101
+ */
102
+ export function stripJsonc(src) {
103
+ let out = ''
104
+ for (let i = 0, inStr = false; i < src.length; i++) {
105
+ const c = src[i]
106
+ if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
107
+ if (c === '"') { inStr = true; out += c; continue }
108
+ if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
109
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
110
+ out += c
111
+ }
112
+ let res = ''
113
+ for (let i = 0, inStr = false; i < out.length; i++) {
114
+ const c = out[i]
115
+ if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
116
+ if (c === '"') { inStr = true; res += c; continue }
117
+ if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
118
+ res += c
119
+ }
120
+ return res
121
+ }
122
+
123
+ /** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
124
+ export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
125
+ const entry = { command: node, args: [runtimeJs] }
126
+ // What the user added to an existing entry is theirs: a token, a relay, a working directory.
127
+ // Only the launch itself -- command and args -- is ours to replace.
128
+ if (previous && typeof previous === 'object') {
129
+ if (previous.env && typeof previous.env === 'object') entry.env = previous.env
130
+ if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
131
+ }
132
+ if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
133
+ return entry
134
+ }
135
+
136
+ /**
137
+ * Write the hivecode entry into one config file. Returns { status, detail }, where status is one
138
+ * of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
139
+ */
140
+ export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
141
+ let raw = ''
142
+ try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
143
+ let cfg = {}
144
+ let droppedComments = false
145
+ if (raw.trim()) {
146
+ const text = raw.replace(/^/, '')
147
+ try { cfg = JSON.parse(text) } catch {
148
+ // JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
149
+ // the user back the hand-editing this command exists to remove; the original is kept.
150
+ try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
151
+ return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
152
+ }
153
+ }
154
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
155
+ }
156
+ const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
157
+ const previous = servers[SERVER_KEY] || null
158
+ if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
159
+ const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
160
+ const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
161
+ // CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
162
+ // told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
163
+ // followed the old README therefore has a broken npx entry that would silently win over the one
164
+ // written above -- and this command would have printed READY about it. Those are ours by name.
165
+ let projectFixes = 0
166
+ if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
167
+ for (const [p, v] of Object.entries(cfg.projects)) {
168
+ const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
169
+ if (!old || typeof old !== 'object') continue
170
+ const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
171
+ if (JSON.stringify(fixed) === JSON.stringify(old)) continue
172
+ if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
173
+ next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
174
+ projectFixes++
175
+ }
176
+ }
177
+ if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
178
+ if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
179
+ try {
180
+ fs.mkdirSync(path.dirname(target.file), { recursive: true })
181
+ // The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
182
+ // second or tenth run can never overwrite the one copy worth going back to.
183
+ if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
184
+ const tmp = target.file + '.hivecode-tmp'
185
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
186
+ try { fs.renameSync(tmp, target.file) } catch {
187
+ // A locked destination (an app holding it, an antivirus scan) refuses the rename on
188
+ // Windows. Writing in place is less atomic but still leaves a whole file behind.
189
+ fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
190
+ }
191
+ } catch (e) { return { status: 'error', detail: e.message } }
192
+ let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
193
+ if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
194
+ if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
195
+ return { status: 'written', detail }
196
+ }
197
+
198
+ /**
199
+ * Every hivecode entry in a written file must launch the same thing -- including Claude Code's
200
+ * per-folder ones, which are never verified by launching separately. Returns the first entry
201
+ * that disagrees with `entry`, or null.
202
+ */
203
+ export function strayEntry(file, entry) {
204
+ let cfg
205
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
206
+ for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
207
+ const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
208
+ if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
209
+ }
210
+ return null
211
+ }
212
+
213
+ /** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
214
+ export function readEntry(file) {
215
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
216
+ }
217
+
218
+ /**
219
+ * Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
220
+ * complete a real `initialize` handshake. Anything short of a hivecode server answering is a
221
+ * failure with the reason attached.
222
+ */
223
+ export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
224
+ return new Promise((resolve) => {
225
+ if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
226
+ const t0 = Date.now()
227
+ const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
228
+ let child
229
+ try {
230
+ child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
231
+ } catch (e) { return resolve({ ok: false, error: e.message }) }
232
+ let out = ''
233
+ let errTail = ''
234
+ let settled = false
235
+ const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
236
+ const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
237
+ child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
238
+ child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
239
+ child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
240
+ child.stdout.on('data', (d) => {
241
+ out += d
242
+ let i
243
+ while ((i = out.indexOf('\n')) >= 0) {
244
+ const line = out.slice(0, i); out = out.slice(i + 1)
245
+ let m; try { m = JSON.parse(line) } catch { continue }
246
+ if (m.id !== 1) continue
247
+ const info = m.result && m.result.serverInfo
248
+ if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
249
+ else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
250
+ }
251
+ })
252
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'hivecode-setup', version: VERSION } } }) + '\n')
253
+ })
254
+ }
255
+
256
+ /**
257
+ * Put a runtime where agent apps can launch it with no download. When this file is running out
258
+ * of a checkout (development), the checkout IS the runtime. When it is running out of an
259
+ * installed package (npx), the package and its dependency closure are copied under
260
+ * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
261
+ * npm prunes it and the config would break months later with nothing to connect it to setup.
262
+ */
263
+ export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url), { log } = {}) {
264
+ const pkgDir = path.dirname(here)
265
+ const nm = path.dirname(pkgDir)
266
+ if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
267
+ const dest = path.join(home, '.hivecode', 'runtime', VERSION)
268
+ const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
269
+ if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
270
+ if (log) log(` installing runtime under ${dest} (first run can take ~25s on Windows)...`)
271
+ // ONLY the dependency closure is copied, never the whole node_modules around us: that would
272
+ // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
273
+ const closure = new Set()
274
+ const seen = new Set()
275
+ const visit = (name, fromDir) => {
276
+ // Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
277
+ // requiring package, skipping directories that ARE node_modules (Node never looks in
278
+ // node_modules/node_modules), and stopping at the folder that holds our node_modules.
279
+ for (let d = fromDir; ;) {
280
+ if (path.basename(d) !== 'node_modules') {
281
+ const cand = path.join(d, 'node_modules', name)
282
+ if (fs.existsSync(path.join(cand, 'package.json'))) {
283
+ if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
284
+ seen.add(cand)
285
+ // Nested copies travel inside their parent's directory; only top-level ones are listed.
286
+ if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
287
+ let pj = {}
288
+ try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
289
+ for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
290
+ return
291
+ }
292
+ }
293
+ const up = path.dirname(d)
294
+ if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
295
+ d = up
296
+ }
297
+ }
298
+ closure.add('hivecode-mcp')
299
+ let rootPj = {}
300
+ try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
301
+ for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
302
+ const tmp = `${dest}.tmp-${process.pid}`
303
+ fs.rmSync(tmp, { recursive: true, force: true })
304
+ for (const name of closure) {
305
+ fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
306
+ }
307
+ fs.rmSync(dest, { recursive: true, force: true })
308
+ fs.renameSync(tmp, dest)
309
+ return { mode: 'installed', js, dir: dest, packages: closure.size }
310
+ }
311
+
312
+ /** The project this was run in, when there is one to offer: a git work tree with at least one
313
+ * commit, whose top is not the home folder (a home kept in git is a dotfiles repo, not a project;
314
+ * this machine's is one). Returns the repo's top folder, or null. Any git failure is null: setup
315
+ * must never fail because git is missing. */
316
+ export function projectRepo(cwd = process.cwd(), home = os.homedir()) {
317
+ const git = (...args) => {
318
+ try {
319
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 10000, windowsHide: true })
320
+ return r.status === 0 ? String(r.stdout || '').trim() : ''
321
+ } catch { return '' }
322
+ }
323
+ const top = git('rev-parse', '--show-toplevel')
324
+ if (!top || !git('rev-parse', '--verify', '-q', 'HEAD^{commit}')) return null
325
+ // THROUGH LINKS (6 Oct 2026, the first macOS run). git names the top folder with links resolved,
326
+ // and the home folder comes as written. macOS temp folders sit behind one (/var is a link to
327
+ // /private/var), so a home kept in git was offered as a project there (F7). Both sides are resolved.
328
+ const norm = (p) => {
329
+ let r = path.resolve(p)
330
+ try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
331
+ return process.platform === 'win32' ? r.toLowerCase() : r
332
+ }
333
+ return norm(top) === norm(home) ? null : path.resolve(top)
334
+ }
335
+
336
+ function defaultName() {
337
+ let base = 'agent'
338
+ try { base = os.userInfo().username || base } catch {}
339
+ const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
340
+ const v = validateMemberName(cand)
341
+ return v.ok ? cand : v.suggested
342
+ }
343
+
344
+ /** Exported for tests/proxy-fetch-test.mjs. */
345
+ export async function wakeRelay(relay) {
346
+ const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
347
+ const t0 = Date.now()
348
+ try {
349
+ const r = await relayFetch(url, { signal: AbortSignal.timeout(75000) })
350
+ return { ok: r.ok, ms: Date.now() - t0, status: r.status }
351
+ } catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
352
+ }
353
+
354
+ function parseArgs(argv) {
355
+ const o = { link: '', name: '', dryRun: false, help: false }
356
+ for (let i = 0; i < argv.length; i++) {
357
+ const a = argv[i]
358
+ if (a === '--name') o.name = argv[++i] || ''
359
+ else if (a.startsWith('--name=')) o.name = a.slice(7)
360
+ else if (a === '--dry-run') o.dryRun = true
361
+ else if (a === '-h' || a === '--help') o.help = true
362
+ else if (!a.startsWith('-') && !o.link) o.link = a
363
+ }
364
+ return o
365
+ }
366
+
367
+ const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
368
+
369
+ /** Returns the process exit code: 0 only when nothing that exists failed. */
370
+ export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
371
+ const o = parseArgs(argv)
372
+ if (o.help) {
373
+ log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
374
+ return 0
375
+ }
376
+ log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
377
+ const major = Number(process.versions.node.split('.')[0])
378
+ if (major < 18) { log(` FAILED Node ${process.versions.node} is too old. Install Node 18 or newer from https://nodejs.org and run this again.`); return 1 }
379
+
380
+ let name = o.name || defaultName()
381
+ const nv = validateMemberName(name)
382
+ if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
383
+
384
+ let runtime
385
+ 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 }) }
386
+ catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
387
+ log(` runtime ${runtime.js}`)
388
+ log(` node ${process.execPath}\n`)
389
+
390
+ const results = []
391
+ for (const t of agentTargets(home)) {
392
+ // A second config path of an app that IS installed is not an app of its own: printing
393
+ // "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
394
+ if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
395
+ const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
396
+ if (w.status !== 'skipped') results.push({ t, ...w })
397
+ }
398
+ // Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
399
+ const cache = new Map()
400
+ for (const r of results) {
401
+ if (r.status !== 'written' && r.status !== 'unchanged') continue
402
+ if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
403
+ const entry = readEntry(r.t.file)
404
+ const key = JSON.stringify(entry)
405
+ if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
406
+ r.verify = cache.get(key)
407
+ const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
408
+ if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
409
+ }
410
+ // A project's own .mcp.json outranks everything written above, inside that project. It is a
411
+ // shared, committed file, so it is reported rather than rewritten with this machine's paths.
412
+ const projectMcp = path.join(process.cwd(), '.mcp.json')
413
+ // Removing the entry only here did not last on 9 Oct 2026: the file was committed, and a checkout put it back.
414
+ 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`)
415
+
416
+ let failed = 0
417
+ const ready = []
418
+ // A second config file of an app is not an app of its own. A READY one folds into its app's line
419
+ // ("Antigravity (2nd) READY" under "Antigravity READY" made a stranger ask which one was real);
420
+ // a FAILED one is never folded -- it keeps a line of its own.
421
+ const isReady = (r) => (r.status === 'written' || r.status === 'unchanged') && r.verify && r.verify.ok
422
+ const folded = new Set(results.filter((r) => r.t.secondaryOf && isReady(r) && results.some((p) => p.t.id === r.t.secondaryOf && isReady(p))))
423
+ for (const r of results) {
424
+ if (folded.has(r)) continue
425
+ const where = r.t.file
426
+ if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
427
+ if (r.status === 'refused' || r.status === 'error') {
428
+ failed++
429
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
430
+ continue
431
+ }
432
+ if (!r.verify.ok) {
433
+ failed++
434
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
435
+ continue
436
+ }
437
+ const extra = results.filter((x) => folded.has(x) && x.t.secondaryOf === r.t.id)
438
+ r.changed = r.status === 'written' || extra.some((x) => x.status === 'written')
439
+ ready.push(r)
440
+ const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
441
+ const what = r.status === 'written' ? r.detail : r.changed ? 'updated its second config file' : 'already set up'
442
+ log(` ${pad(r.t.label, 15)} READY ${what} (${how}${extra.length ? `, ${extra.length + 1} config files` : ''})`)
443
+ }
444
+
445
+ // THE STOP GATE, FOR CLAUDE CODE (4 Oct 2026). An agent in a room went deaf the moment it ended a
446
+ // turn with no listener running, and the gate that stops that (hive-stop-hook.js) was a separate
447
+ // command nobody ran. Setup configures the agent apps, so it arms the gate too: in
448
+ // ~/.claude/settings.json beside any Stop hook already there, merged, never replaced. Only once
449
+ // the hook has been seen to run, and only for a Claude Code setup made READY -- a hook that cannot
450
+ // start fails on every turn of every session on the machine.
451
+ const cc = ready.find((r) => r.t.claudeCode)
452
+ if (cc) {
453
+ const node = stableNodePath().replace(/\\/g, '/')
454
+ const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
455
+ const settings = path.join(home, '.claude', 'settings.json')
456
+ // stop_hook_active: the hook loads everything it imports, then lets the stop through untouched.
457
+ const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
458
+ if (ran.status !== 0) {
459
+ log(` ${pad('stop gate', 15)} NOT added: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
460
+ } else {
461
+ const h = installStopHook(settings, `"${node}" "${hookJs}"`, { dryRun: o.dryRun })
462
+ if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added: ${h.detail}. Fix that file, then run setup again.`)
463
+ else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${settings}`)
464
+ else {
465
+ cc.changed = true // a hook loads only when Claude Code starts, so it is on the restart list
466
+ log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${settings}: in a room, Claude Code is not let end a turn with no listener running${h.others ? ` (${h.others} other Stop hook${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
467
+ }
468
+ }
469
+ }
470
+
471
+ const agy = ready.find((r) => r.t.id === 'antigravity')
472
+ if (agy) {
473
+ const node = stableNodePath().replace(/\\/g, '/')
474
+ const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
475
+ const hooksPath = path.join(home, '.gemini', 'config', 'hooks.json')
476
+ const cmd = `"${node}" "${hookJs}" --app=antigravity`
477
+ const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs, '--app=antigravity'], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
478
+ if (ran.status !== 0) {
479
+ log(` ${pad('stop gate', 15)} NOT added for Antigravity: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
480
+ } else {
481
+ const h = installAntigravityStopHook(hooksPath, cmd, { dryRun: o.dryRun })
482
+ if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added for Antigravity: ${h.detail}. Fix that file, then run setup again.`)
483
+ else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${hooksPath}`)
484
+ else {
485
+ agy.changed = true
486
+ log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${hooksPath}: in a room, Antigravity is not let end a turn with no listener running${h.others ? ` (${h.others} other hook group${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
487
+ }
488
+ }
489
+ }
490
+
491
+ const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
492
+ if (failed || !ready.length) {
493
+ log(`\n ${ready.length ? 'For the agents above that FAILED' : 'No supported agent app was found. For any MCP agent'}, add this to its MCP settings:\n`)
494
+ log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
495
+ }
496
+
497
+ let link = o.link.trim()
498
+ if (link) {
499
+ const [relayPart, ...rest] = link.split('|')
500
+ const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
501
+ if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
502
+ if (!rest.length || !rest[0].trim()) {
503
+ // THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
504
+ // one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
505
+ // explanation also goes to stderr, the one stream still reaching the terminal.
506
+ const hint = `\n WARNING "${o.link}" has no room in it -- the part after | was cut off. Put the whole link in quotes, or leave it out: you give the link to your agent, not to this command.`
507
+ log(hint)
508
+ if (!process.stdout.isTTY) console.error(hint)
509
+ }
510
+ const w = await wakeRelay(relay)
511
+ log(w.ok ? `\n relay ${relay} is up (answered in ${(w.ms / 1000).toFixed(1)}s)` : `\n WARNING ${relay} did not answer (${w.error || 'HTTP ' + w.status}). Joining will fail until it does.`)
512
+ }
513
+
514
+ if (ready.length) {
515
+ log(`\nNEXT\n`)
516
+ // Numbered steps only where this run wrote something. An entry that was already right says
517
+ // nothing about whether the RUNNING app has loaded it -- "run setup again, then reload the agent"
518
+ // is the README's first fix for a missing hive_join -- so those apps get one line, never silence.
519
+ const toRestart = ready.filter((r) => r.changed)
520
+ const already = ready.filter((r) => !r.changed)
521
+ toRestart.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
522
+ if (already.length) log(` Already set up: ${already.map((r) => r.t.label).join(', ')}. Restart ${already.length === 1 ? 'it' : 'them'} only if the hivecode tools are missing there.`)
523
+ if (link) {
524
+ log(`\n Then paste this to your agent:\n`)
525
+ log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
526
+ } else {
527
+ // THE EMPTY-ROOM TRAP (onboarding audit, 2 Oct 2026). A team runs setup in its project and
528
+ // is told to host a room, which is new and empty: its code is not in it, and nothing said how
529
+ // to bring it in. `branch` does, so inside a repo it comes first -- with the privacy line
530
+ // BEFORE the command, because running it publishes the repo's committed files. The name is
531
+ // a real one: `<name>` pasted into PowerShell is a parse error, not a placeholder.
532
+ const repo = projectRepo(process.cwd(), home)
533
+ if (repo) {
534
+ const relay = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY
535
+ log(`\n You're in a git repo (${repo}). To give your agents its code, run this here first:\n`)
536
+ log(` npx -y hivecode-mcp@latest branch hive-work\n`)
537
+ log(` It cuts a new branch, hive-work, without switching your checkout, puts its committed files in a room,`)
538
+ log(` and prints the line for your agents and for teammates.${currentRelayFor(relay).relay === PUBLIC_DEFAULT_RELAY ? ' This uses the public relay: anyone with the room\'s link can read those files.' : ''}`)
539
+ }
540
+ // Inside a repo the empty-room line is the trap itself: Antigravity1's scripted stranger walk
541
+ // (3 Oct) found a stranger skips the command above and pastes "host a new, empty room". Only
542
+ // the line for someone who was SENT a link stays; the README still documents an empty room.
543
+ if (repo) {
544
+ log(`\n If you were sent a link instead, paste this to your agent:\n`)
545
+ log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
546
+ } else {
547
+ log(`\n Then paste ONE of these to your agent:\n`)
548
+ log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
549
+ log(` Use your hive_join tool to host a new, empty Hivecode room as ${name}, then give me the join link.`)
550
+ }
551
+ }
552
+ log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
553
+ log(` Two agents on one machine: open two Claude Code terminals, or pair two apps (GUI editors share one MCP process across tabs).`)
554
+ // With a link, the room itself: its relay's Control Room with the link in the hash, so a person
555
+ // never pastes wss:// into an address bar. Without one, the panel they paste a link into.
556
+ const panel = (link && controlUrl(link)) || `${(process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).replace(/^ws/, 'http').replace(/\/+$/, '')}/control`
557
+ log(` People join from a browser, no install: ${panel}\n`)
558
+ }
559
+ return failed ? 1 : 0
560
+ }