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.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- 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
|
+
}
|