hivecode-mcp 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -280
- package/docs/MCP.md +18 -10
- package/docs/SELF_HOSTING.md +10 -7
- package/hive-mcp.js +108 -12
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-privacy.js +25 -0
- package/hive-relay-health.js +24 -4
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/limits.js +49 -1
- package/package.json +17 -7
- package/sync.js +113 -26
package/hive-relay-health.js
CHANGED
|
@@ -132,13 +132,33 @@ export function diagnose(probe, { stuck = 0, what = 'files' } = {}) {
|
|
|
132
132
|
const seats = h.seats ? ` (${h.seats} seats)` : ''
|
|
133
133
|
const facts = `I asked ${url}: ${conns} of ${total} connections in use, ${h.rooms || 0} rooms, per-room cap ${perRoom}${seats}.`
|
|
134
134
|
|
|
135
|
+
// ONE SOCKET PER PARTICIPANT has its own ceiling: the files open across every participant's
|
|
136
|
+
// shared socket (Q438). Checked first, because at that ceiling the socket count looks healthy.
|
|
137
|
+
const chanCap = Number(h.limits && h.limits.maxChannelsTotal) || 0
|
|
138
|
+
const chans = Number(h.channels) || 0
|
|
139
|
+
if (Number(h.mux) >= 1 && chanCap && chans >= chanCap * NEAR_CAPACITY) {
|
|
140
|
+
return {
|
|
141
|
+
verdict: RELAY_FULL,
|
|
142
|
+
text: [lead, `I asked ${url}: ${chans} of ${chanCap} open files in use across all participants.`,
|
|
143
|
+
`THE RELAY IS AT ITS OPEN-FILE CAPACITY — this is Q438. Raise HIVE_MAX_CHANNELS_TOTAL on the relay,`,
|
|
144
|
+
`or narrow what syncs.`].join(' '),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
if (total && conns >= total * NEAR_CAPACITY) {
|
|
149
|
+
// Who spends the sockets depends on the transport. A relay offering one-socket mode charges
|
|
150
|
+
// those clients 2 each; the older transport, still spoken by the VS Code extension, the browser
|
|
151
|
+
// panel and HIVE_MUX=0, charges one PER FILE -- and that is what exhausts a relay.
|
|
152
|
+
const who = Number(h.mux) >= 1
|
|
153
|
+
? `This relay carries each participant's files on one socket (2 sockets a participant), but clients`
|
|
154
|
+
+ ` on the older transport -- the VS Code extension, the browser panel, HIVE_MUX=0 -- still open one`
|
|
155
|
+
+ ` socket PER FILE, and one large folder on those can exhaust it alone.`
|
|
156
|
+
: `Hivecode opens one socket PER FILE, so a single large folder can exhaust it alone.`
|
|
136
157
|
return {
|
|
137
158
|
verdict: RELAY_FULL,
|
|
138
159
|
text: [lead, facts,
|
|
139
|
-
`THE RELAY IS AT OR NEAR CAPACITY — this is Q430
|
|
140
|
-
`
|
|
141
|
-
`what syncs.`].join(' '),
|
|
160
|
+
`THE RELAY IS AT OR NEAR CAPACITY — this is Q430.`, who,
|
|
161
|
+
`Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow what syncs.`].join(' '),
|
|
142
162
|
}
|
|
143
163
|
}
|
|
144
164
|
|
|
@@ -198,7 +218,7 @@ export function explainStatus(code, relay = '') {
|
|
|
198
218
|
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
199
219
|
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
200
220
|
if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
|
|
201
|
-
if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
|
|
221
|
+
if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
|
|
202
222
|
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
203
223
|
return `the relay at ${relay || 'the relay'} cannot be reached. It may be down, asleep, or blocked by a network. Contact support@getgrm.tech if this continues.`
|
|
204
224
|
}
|
package/hive-setup.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
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 } from 'child_process'
|
|
38
|
+
import { fileURLToPath } from 'url'
|
|
39
|
+
import { VERSION } from './hive-version.js'
|
|
40
|
+
import { currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
41
|
+
import { validateMemberName } from './hive-mailbox.js'
|
|
42
|
+
|
|
43
|
+
export const SERVER_KEY = 'hivecode'
|
|
44
|
+
const BACKUP_SUFFIX = '.before-hivecode'
|
|
45
|
+
|
|
46
|
+
/** Every agent app we know how to configure, and where each keeps its MCP servers. */
|
|
47
|
+
export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
48
|
+
const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
|
|
49
|
+
: platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
|
|
50
|
+
: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
|
|
51
|
+
// `present` is how we decide the app is installed. It is deliberately the app's OWN directory
|
|
52
|
+
// (or, where the app creates it on first run, its own file) -- never "the file we would write",
|
|
53
|
+
// which would make every run create configs for apps the user does not have.
|
|
54
|
+
return [
|
|
55
|
+
{ id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
|
|
56
|
+
restart: 'start a new Claude Code session (or restart `claude`)' },
|
|
57
|
+
{ id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
|
|
58
|
+
restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
|
|
59
|
+
// Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
|
|
60
|
+
// a stale one there could shadow the fresh one above -- and never created.
|
|
61
|
+
{ id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
|
|
62
|
+
onlyIfHasEntry: true, restart: 'same refresh as above' },
|
|
63
|
+
{ id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
|
|
64
|
+
restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
|
|
65
|
+
{ id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
|
|
66
|
+
restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
|
|
67
|
+
{ id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
|
|
68
|
+
restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
|
|
69
|
+
// The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
|
|
70
|
+
// settings file does: the CLI writes it on first run.
|
|
71
|
+
{ id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
|
|
72
|
+
restart: 'restart `gemini`' },
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
|
|
78
|
+
* started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
|
|
79
|
+
* absolute path that is still this same program. process.execPath on Homebrew is the versioned
|
|
80
|
+
* Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
|
|
81
|
+
* the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
|
|
82
|
+
* can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
|
|
83
|
+
*/
|
|
84
|
+
export function stableNodePath(execPath = process.execPath, env = process.env) {
|
|
85
|
+
const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
|
|
86
|
+
const target = real(execPath)
|
|
87
|
+
if (!target) return execPath
|
|
88
|
+
const exe = process.platform === 'win32' ? 'node.exe' : 'node'
|
|
89
|
+
const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
|
|
90
|
+
const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
|
|
91
|
+
const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
|
|
92
|
+
for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
|
|
93
|
+
return execPath
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
|
|
98
|
+
* trailing commas, all outside strings. Returns the plain-JSON text.
|
|
99
|
+
*/
|
|
100
|
+
export function stripJsonc(src) {
|
|
101
|
+
let out = ''
|
|
102
|
+
for (let i = 0, inStr = false; i < src.length; i++) {
|
|
103
|
+
const c = src[i]
|
|
104
|
+
if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
|
|
105
|
+
if (c === '"') { inStr = true; out += c; continue }
|
|
106
|
+
if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
|
|
107
|
+
if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
|
|
108
|
+
out += c
|
|
109
|
+
}
|
|
110
|
+
let res = ''
|
|
111
|
+
for (let i = 0, inStr = false; i < out.length; i++) {
|
|
112
|
+
const c = out[i]
|
|
113
|
+
if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
|
|
114
|
+
if (c === '"') { inStr = true; res += c; continue }
|
|
115
|
+
if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
|
|
116
|
+
res += c
|
|
117
|
+
}
|
|
118
|
+
return res
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
|
|
122
|
+
export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
|
|
123
|
+
const entry = { command: node, args: [runtimeJs] }
|
|
124
|
+
// What the user added to an existing entry is theirs: a token, a relay, a working directory.
|
|
125
|
+
// Only the launch itself -- command and args -- is ours to replace.
|
|
126
|
+
if (previous && typeof previous === 'object') {
|
|
127
|
+
if (previous.env && typeof previous.env === 'object') entry.env = previous.env
|
|
128
|
+
if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
|
|
129
|
+
}
|
|
130
|
+
if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
|
|
131
|
+
return entry
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Write the hivecode entry into one config file. Returns { status, detail }, where status is one
|
|
136
|
+
* of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
|
|
137
|
+
*/
|
|
138
|
+
export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
|
|
139
|
+
let raw = ''
|
|
140
|
+
try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
|
|
141
|
+
let cfg = {}
|
|
142
|
+
let droppedComments = false
|
|
143
|
+
if (raw.trim()) {
|
|
144
|
+
const text = raw.replace(/^/, '')
|
|
145
|
+
try { cfg = JSON.parse(text) } catch {
|
|
146
|
+
// JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
|
|
147
|
+
// the user back the hand-editing this command exists to remove; the original is kept.
|
|
148
|
+
try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
|
|
149
|
+
return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
|
|
153
|
+
}
|
|
154
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
|
|
155
|
+
const previous = servers[SERVER_KEY] || null
|
|
156
|
+
if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
|
|
157
|
+
const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
|
|
158
|
+
const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
|
|
159
|
+
// CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
|
|
160
|
+
// told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
|
|
161
|
+
// followed the old README therefore has a broken npx entry that would silently win over the one
|
|
162
|
+
// written above -- and this command would have printed READY about it. Those are ours by name.
|
|
163
|
+
let projectFixes = 0
|
|
164
|
+
if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
|
|
165
|
+
for (const [p, v] of Object.entries(cfg.projects)) {
|
|
166
|
+
const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
|
|
167
|
+
if (!old || typeof old !== 'object') continue
|
|
168
|
+
const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
|
|
169
|
+
if (JSON.stringify(fixed) === JSON.stringify(old)) continue
|
|
170
|
+
if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
|
|
171
|
+
next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
|
|
172
|
+
projectFixes++
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
|
|
176
|
+
if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(path.dirname(target.file), { recursive: true })
|
|
179
|
+
// The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
|
|
180
|
+
// second or tenth run can never overwrite the one copy worth going back to.
|
|
181
|
+
if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
|
|
182
|
+
const tmp = target.file + '.hivecode-tmp'
|
|
183
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
|
|
184
|
+
try { fs.renameSync(tmp, target.file) } catch {
|
|
185
|
+
// A locked destination (an app holding it, an antivirus scan) refuses the rename on
|
|
186
|
+
// Windows. Writing in place is less atomic but still leaves a whole file behind.
|
|
187
|
+
fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
|
|
188
|
+
}
|
|
189
|
+
} catch (e) { return { status: 'error', detail: e.message } }
|
|
190
|
+
let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
|
|
191
|
+
if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
|
|
192
|
+
if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
|
|
193
|
+
return { status: 'written', detail }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every hivecode entry in a written file must launch the same thing -- including Claude Code's
|
|
198
|
+
* per-folder ones, which are never verified by launching separately. Returns the first entry
|
|
199
|
+
* that disagrees with `entry`, or null.
|
|
200
|
+
*/
|
|
201
|
+
export function strayEntry(file, entry) {
|
|
202
|
+
let cfg
|
|
203
|
+
try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
|
|
204
|
+
for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
|
|
205
|
+
const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
|
|
206
|
+
if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
|
|
212
|
+
export function readEntry(file) {
|
|
213
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
|
|
218
|
+
* complete a real `initialize` handshake. Anything short of a hivecode server answering is a
|
|
219
|
+
* failure with the reason attached.
|
|
220
|
+
*/
|
|
221
|
+
export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
|
|
224
|
+
const t0 = Date.now()
|
|
225
|
+
const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
|
|
226
|
+
let child
|
|
227
|
+
try {
|
|
228
|
+
child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
229
|
+
} catch (e) { return resolve({ ok: false, error: e.message }) }
|
|
230
|
+
let out = ''
|
|
231
|
+
let errTail = ''
|
|
232
|
+
let settled = false
|
|
233
|
+
const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
|
|
234
|
+
const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
|
|
235
|
+
child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
|
|
236
|
+
child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
|
|
237
|
+
child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
|
|
238
|
+
child.stdout.on('data', (d) => {
|
|
239
|
+
out += d
|
|
240
|
+
let i
|
|
241
|
+
while ((i = out.indexOf('\n')) >= 0) {
|
|
242
|
+
const line = out.slice(0, i); out = out.slice(i + 1)
|
|
243
|
+
let m; try { m = JSON.parse(line) } catch { continue }
|
|
244
|
+
if (m.id !== 1) continue
|
|
245
|
+
const info = m.result && m.result.serverInfo
|
|
246
|
+
if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
|
|
247
|
+
else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
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')
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Put a runtime where agent apps can launch it with no download. When this file is running out
|
|
256
|
+
* of a checkout (development), the checkout IS the runtime. When it is running out of an
|
|
257
|
+
* installed package (npx), the package and its dependency closure are copied under
|
|
258
|
+
* ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
|
|
259
|
+
* npm prunes it and the config would break months later with nothing to connect it to setup.
|
|
260
|
+
*/
|
|
261
|
+
export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
|
|
262
|
+
const pkgDir = path.dirname(here)
|
|
263
|
+
const nm = path.dirname(pkgDir)
|
|
264
|
+
if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
|
|
265
|
+
const dest = path.join(home, '.hivecode', 'runtime', VERSION)
|
|
266
|
+
const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
|
|
267
|
+
if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
|
|
268
|
+
// ONLY the dependency closure is copied, never the whole node_modules around us: that would
|
|
269
|
+
// be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
|
|
270
|
+
const closure = new Set()
|
|
271
|
+
const seen = new Set()
|
|
272
|
+
const visit = (name, fromDir) => {
|
|
273
|
+
// Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
|
|
274
|
+
// requiring package, skipping directories that ARE node_modules (Node never looks in
|
|
275
|
+
// node_modules/node_modules), and stopping at the folder that holds our node_modules.
|
|
276
|
+
for (let d = fromDir; ;) {
|
|
277
|
+
if (path.basename(d) !== 'node_modules') {
|
|
278
|
+
const cand = path.join(d, 'node_modules', name)
|
|
279
|
+
if (fs.existsSync(path.join(cand, 'package.json'))) {
|
|
280
|
+
if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
|
|
281
|
+
seen.add(cand)
|
|
282
|
+
// Nested copies travel inside their parent's directory; only top-level ones are listed.
|
|
283
|
+
if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
|
|
284
|
+
let pj = {}
|
|
285
|
+
try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
|
|
286
|
+
for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const up = path.dirname(d)
|
|
291
|
+
if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
|
|
292
|
+
d = up
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
closure.add('hivecode-mcp')
|
|
296
|
+
let rootPj = {}
|
|
297
|
+
try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
|
|
298
|
+
for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
|
|
299
|
+
const tmp = `${dest}.tmp-${process.pid}`
|
|
300
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
301
|
+
for (const name of closure) {
|
|
302
|
+
fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
|
|
303
|
+
}
|
|
304
|
+
fs.rmSync(dest, { recursive: true, force: true })
|
|
305
|
+
fs.renameSync(tmp, dest)
|
|
306
|
+
return { mode: 'installed', js, dir: dest, packages: closure.size }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function defaultName() {
|
|
310
|
+
let base = 'agent'
|
|
311
|
+
try { base = os.userInfo().username || base } catch {}
|
|
312
|
+
const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
|
|
313
|
+
const v = validateMemberName(cand)
|
|
314
|
+
return v.ok ? cand : v.suggested
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function wakeRelay(relay) {
|
|
318
|
+
const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
|
|
319
|
+
const t0 = Date.now()
|
|
320
|
+
try {
|
|
321
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(75000) })
|
|
322
|
+
return { ok: r.ok, ms: Date.now() - t0, status: r.status }
|
|
323
|
+
} catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseArgs(argv) {
|
|
327
|
+
const o = { link: '', name: '', dryRun: false, help: false }
|
|
328
|
+
for (let i = 0; i < argv.length; i++) {
|
|
329
|
+
const a = argv[i]
|
|
330
|
+
if (a === '--name') o.name = argv[++i] || ''
|
|
331
|
+
else if (a.startsWith('--name=')) o.name = a.slice(7)
|
|
332
|
+
else if (a === '--dry-run') o.dryRun = true
|
|
333
|
+
else if (a === '-h' || a === '--help') o.help = true
|
|
334
|
+
else if (!a.startsWith('-') && !o.link) o.link = a
|
|
335
|
+
}
|
|
336
|
+
return o
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
|
|
340
|
+
|
|
341
|
+
/** Returns the process exit code: 0 only when nothing that exists failed. */
|
|
342
|
+
export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
|
|
343
|
+
const o = parseArgs(argv)
|
|
344
|
+
if (o.help) {
|
|
345
|
+
log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
|
|
346
|
+
return 0
|
|
347
|
+
}
|
|
348
|
+
log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
|
|
349
|
+
const major = Number(process.versions.node.split('.')[0])
|
|
350
|
+
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 }
|
|
351
|
+
|
|
352
|
+
let name = o.name || defaultName()
|
|
353
|
+
const nv = validateMemberName(name)
|
|
354
|
+
if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
|
|
355
|
+
|
|
356
|
+
let runtime
|
|
357
|
+
try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
|
|
358
|
+
catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
|
|
359
|
+
log(` runtime ${runtime.js}`)
|
|
360
|
+
log(` node ${process.execPath}\n`)
|
|
361
|
+
|
|
362
|
+
const results = []
|
|
363
|
+
for (const t of agentTargets(home)) {
|
|
364
|
+
// A second config path of an app that IS installed is not an app of its own: printing
|
|
365
|
+
// "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
|
|
366
|
+
if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
|
|
367
|
+
const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
|
|
368
|
+
if (w.status !== 'skipped') results.push({ t, ...w })
|
|
369
|
+
}
|
|
370
|
+
// Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
|
|
371
|
+
const cache = new Map()
|
|
372
|
+
for (const r of results) {
|
|
373
|
+
if (r.status !== 'written' && r.status !== 'unchanged') continue
|
|
374
|
+
if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
|
|
375
|
+
const entry = readEntry(r.t.file)
|
|
376
|
+
const key = JSON.stringify(entry)
|
|
377
|
+
if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
|
|
378
|
+
r.verify = cache.get(key)
|
|
379
|
+
const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
|
|
380
|
+
if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
|
|
381
|
+
}
|
|
382
|
+
// A project's own .mcp.json outranks everything written above, inside that project. It is a
|
|
383
|
+
// shared, committed file, so it is reported rather than rewritten with this machine's paths.
|
|
384
|
+
const projectMcp = path.join(process.cwd(), '.mcp.json')
|
|
385
|
+
if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
|
|
386
|
+
|
|
387
|
+
let failed = 0
|
|
388
|
+
const ready = []
|
|
389
|
+
for (const r of results) {
|
|
390
|
+
const where = r.t.file
|
|
391
|
+
if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
|
|
392
|
+
if (r.status === 'refused' || r.status === 'error') {
|
|
393
|
+
failed++
|
|
394
|
+
log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
if (!r.verify.ok) {
|
|
398
|
+
failed++
|
|
399
|
+
log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
ready.push(r)
|
|
403
|
+
const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
|
|
404
|
+
log(` ${pad(r.t.label, 15)} READY ${r.status === 'unchanged' ? 'already set up' : r.detail} (${how})`)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
|
|
408
|
+
if (failed || !ready.length) {
|
|
409
|
+
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`)
|
|
410
|
+
log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let link = o.link.trim()
|
|
414
|
+
if (link) {
|
|
415
|
+
const [relayPart, ...rest] = link.split('|')
|
|
416
|
+
const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
|
|
417
|
+
if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
|
|
418
|
+
if (!rest.length || !rest[0].trim()) {
|
|
419
|
+
// THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
|
|
420
|
+
// one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
|
|
421
|
+
// explanation also goes to stderr, the one stream still reaching the terminal.
|
|
422
|
+
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.`
|
|
423
|
+
log(hint)
|
|
424
|
+
if (!process.stdout.isTTY) console.error(hint)
|
|
425
|
+
}
|
|
426
|
+
const w = await wakeRelay(relay)
|
|
427
|
+
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.`)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (ready.length) {
|
|
431
|
+
log(`\nNEXT\n`)
|
|
432
|
+
ready.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
|
|
433
|
+
if (link) {
|
|
434
|
+
log(`\n Then paste this to your agent:\n`)
|
|
435
|
+
log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
|
|
436
|
+
} else {
|
|
437
|
+
log(`\n Then paste ONE of these to your agent:\n`)
|
|
438
|
+
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.`)
|
|
439
|
+
log(` Use your hive_join tool to host a new Hivecode room as ${name}, then give me the join link.`)
|
|
440
|
+
}
|
|
441
|
+
log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
|
|
442
|
+
log(` People join from a browser, no install: ${PUBLIC_DEFAULT_RELAY.replace(/^wss:/, 'https:')}/control\n`)
|
|
443
|
+
}
|
|
444
|
+
return failed ? 1 : 0
|
|
445
|
+
}
|
package/hive-stdio.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// hive-stdio.js -- the MCP stdio server Hivecode actually needs, without the SDK's web stack.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. @modelcontextprotocol/sdk is a client AND a server AND three HTTP transports,
|
|
4
|
+
// so installing it installs express, hono, cors, jose, zod, ajv and ~85 other packages -- 25 MB,
|
|
5
|
+
// most of a stranger's first `npx hivecode-mcp` download (36.7s measured cold, 25 Sep 2026). A
|
|
6
|
+
// load trace of a full session (join, claim, write, release, say, wait) showed the client uses
|
|
7
|
+
// exactly one slice of it: newline-delimited JSON-RPC over stdin/stdout, `initialize`, `ping`,
|
|
8
|
+
// `tools/list`, `tools/call`, and two outgoing notifications. That slice is this file.
|
|
9
|
+
//
|
|
10
|
+
// IT MUST BEHAVE EXACTLY LIKE THE SDK WHERE A CLIENT CAN TELL. The SDK is still a devDependency,
|
|
11
|
+
// and tests/mcp-conformance-test.mjs drives this server with the SDK's own CLIENT -- the one
|
|
12
|
+
// Claude Code and the Gemini CLI are built on. Specifically, as in the SDK (1.30):
|
|
13
|
+
// - protocol version: echo the client's if supported, else answer with the newest we know;
|
|
14
|
+
// - `ping` -> {}; an unknown method -> error -32601; a handler that throws -> its own numeric
|
|
15
|
+
// `code` if it has one, else -32603, with the error's message;
|
|
16
|
+
// - a request the client CANCELLED gets no response (the spec: "SHOULD NOT send a response");
|
|
17
|
+
// - a line that is not JSON is logged to stderr and otherwise ignored, never answered.
|
|
18
|
+
// And one thing the SDK does not do: when stdin closes, the server says so (onclose), so the
|
|
19
|
+
// process can EXIT. The spec asks exactly that of a stdio server; the SDK left it running, and a
|
|
20
|
+
// Hivecode server that outlives its agent app keeps a seat in the room for a member who is gone.
|
|
21
|
+
|
|
22
|
+
export const LATEST_PROTOCOL_VERSION = '2025-11-25'
|
|
23
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
|
|
24
|
+
|
|
25
|
+
// Same shape hive-mcp.js used to import from '@modelcontextprotocol/sdk/types.js': something
|
|
26
|
+
// setRequestHandler can take the method name from.
|
|
27
|
+
export const ListToolsRequestSchema = Object.freeze({ method: 'tools/list' })
|
|
28
|
+
export const CallToolRequestSchema = Object.freeze({ method: 'tools/call' })
|
|
29
|
+
|
|
30
|
+
const METHOD_NOT_FOUND = -32601
|
|
31
|
+
const INVALID_PARAMS = -32602
|
|
32
|
+
const INTERNAL_ERROR = -32603
|
|
33
|
+
|
|
34
|
+
export class Server {
|
|
35
|
+
constructor(serverInfo, { capabilities = {}, instructions } = {}) {
|
|
36
|
+
this._serverInfo = serverInfo
|
|
37
|
+
this._capabilities = capabilities
|
|
38
|
+
this._instructions = instructions
|
|
39
|
+
// Keyed by method, called as handler(request, extra) -- the SDK's own internal shape, which
|
|
40
|
+
// the test seam in hive-mcp.js reaches into on purpose.
|
|
41
|
+
this._requestHandlers = new Map()
|
|
42
|
+
this._transport = null
|
|
43
|
+
this._inflight = new Map() // request id -> AbortController, so a cancel can find it
|
|
44
|
+
this.onclose = null
|
|
45
|
+
this.oninitialized = null
|
|
46
|
+
this._requestHandlers.set('initialize', (req) => {
|
|
47
|
+
const requested = req.params && req.params.protocolVersion
|
|
48
|
+
return {
|
|
49
|
+
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION,
|
|
50
|
+
capabilities: this._capabilities,
|
|
51
|
+
serverInfo: this._serverInfo,
|
|
52
|
+
...(this._instructions ? { instructions: this._instructions } : {}),
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
this._requestHandlers.set('ping', () => ({}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setRequestHandler(schema, handler) {
|
|
59
|
+
const method = typeof schema === 'string' ? schema : schema && schema.method
|
|
60
|
+
if (!method) throw new Error('setRequestHandler needs a method')
|
|
61
|
+
this._requestHandlers.set(method, handler)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async connect(transport) {
|
|
65
|
+
this._transport = transport
|
|
66
|
+
transport.onmessage = (msg) => this._onmessage(msg)
|
|
67
|
+
transport.onclose = () => { this._transport = null; if (this.onclose) this.onclose() }
|
|
68
|
+
await transport.start()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Send a notification to the client. Rejects when there is nobody to send it to. */
|
|
72
|
+
notification({ method, params }) {
|
|
73
|
+
if (!this._transport) return Promise.reject(new Error('Not connected'))
|
|
74
|
+
return this._transport.send({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_onmessage(msg) {
|
|
78
|
+
if (Array.isArray(msg)) {
|
|
79
|
+
// JSON-RPC batches: the 2025-03-26 revision allowed them. Answer each request in the batch;
|
|
80
|
+
// the replies go back one per line, which every client matches by id.
|
|
81
|
+
for (const m of msg) this._onmessage(m)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
|
|
85
|
+
if (msg.method === undefined) return // a response to a request we never send
|
|
86
|
+
if (msg.id === undefined || msg.id === null) return this._onnotification(msg)
|
|
87
|
+
this._onrequest(msg)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_onnotification(msg) {
|
|
91
|
+
if (msg.method === 'notifications/initialized') { if (this.oninitialized) this.oninitialized(); return }
|
|
92
|
+
if (msg.method === 'notifications/cancelled') {
|
|
93
|
+
const id = msg.params && msg.params.requestId
|
|
94
|
+
const ac = this._inflight.get(id)
|
|
95
|
+
if (ac) ac.abort()
|
|
96
|
+
}
|
|
97
|
+
// every other notification is ignorable by spec
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async _onrequest(msg) {
|
|
101
|
+
const { id, method } = msg
|
|
102
|
+
const handler = this._requestHandlers.get(method)
|
|
103
|
+
if (!handler) return this._reply({ jsonrpc: '2.0', id, error: { code: METHOD_NOT_FOUND, message: 'Method not found' } })
|
|
104
|
+
if (method === 'tools/call' && (!msg.params || typeof msg.params !== 'object' || typeof msg.params.name !== 'string')) {
|
|
105
|
+
return this._reply({ jsonrpc: '2.0', id, error: { code: INVALID_PARAMS, message: 'tools/call needs params.name' } })
|
|
106
|
+
}
|
|
107
|
+
const ac = new AbortController()
|
|
108
|
+
this._inflight.set(id, ac)
|
|
109
|
+
let reply
|
|
110
|
+
try {
|
|
111
|
+
const result = await handler({ method, params: msg.params }, { signal: ac.signal, requestId: id })
|
|
112
|
+
reply = { jsonrpc: '2.0', id, result: result === undefined ? {} : result }
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const code = e && Number.isSafeInteger(e.code) ? e.code : INTERNAL_ERROR
|
|
115
|
+
reply = { jsonrpc: '2.0', id, error: { code, message: (e && e.message) || 'Internal error' } }
|
|
116
|
+
} finally {
|
|
117
|
+
this._inflight.delete(id)
|
|
118
|
+
}
|
|
119
|
+
if (ac.signal.aborted) return // cancelled by the client: it is no longer waiting for this
|
|
120
|
+
this._reply(reply)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_reply(message) {
|
|
124
|
+
if (!this._transport) return
|
|
125
|
+
Promise.resolve(this._transport.send(message)).catch((e) => process.stderr.write(`[hive-stdio] could not reply: ${e && e.message}\n`))
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class StdioServerTransport {
|
|
130
|
+
constructor(stdin = process.stdin, stdout = process.stdout) {
|
|
131
|
+
this._stdin = stdin
|
|
132
|
+
this._stdout = stdout
|
|
133
|
+
this._buf = ''
|
|
134
|
+
this._closed = false
|
|
135
|
+
this.onmessage = null
|
|
136
|
+
this.onclose = null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async start() {
|
|
140
|
+
this._stdin.setEncoding('utf8') // a multi-byte character split across two chunks stays whole
|
|
141
|
+
this._stdin.on('data', (chunk) => {
|
|
142
|
+
this._buf += chunk
|
|
143
|
+
let i
|
|
144
|
+
while ((i = this._buf.indexOf('\n')) >= 0) {
|
|
145
|
+
const line = this._buf.slice(0, i).replace(/\r$/, '')
|
|
146
|
+
this._buf = this._buf.slice(i + 1)
|
|
147
|
+
if (!line.trim()) continue
|
|
148
|
+
let msg
|
|
149
|
+
try { msg = JSON.parse(line) } catch {
|
|
150
|
+
process.stderr.write(`[hive-stdio] ignored a line that is not JSON (${line.length} chars)\n`)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
try { if (this.onmessage) this.onmessage(msg) } catch (e) { process.stderr.write(`[hive-stdio] ${e && e.stack}\n`) }
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
const close = () => { if (this._closed) return; this._closed = true; if (this.onclose) this.onclose() }
|
|
157
|
+
this._stdin.on('end', close)
|
|
158
|
+
this._stdin.on('close', close)
|
|
159
|
+
this._stdin.on('error', close)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
send(message) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
if (this._closed && !this._stdout.writable) return reject(new Error('stdout is closed'))
|
|
165
|
+
this._stdout.write(JSON.stringify(message) + '\n', (err) => (err ? reject(err) : resolve()))
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|