hivecode-mcp 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,353 @@
1
+ // hive-transport.js -- how a client reaches the relay: a WebSocket where the network allows one, and
2
+ // HTTPS long-poll where it does not (wire format and why: hive-poll-proto.js).
3
+ //
4
+ // HiveSocket is what every provider is given as its WebSocketPolyfill. It behaves like a WebSocket
5
+ // to y-websocket and to the mux client (readyState, binaryType, onopen/onmessage/onerror/onclose,
6
+ // send, close, terminate), so nothing above it knows or cares which pipe carries the bytes.
7
+ //
8
+ // THE DECISION, per relay, made once and remembered for the process:
9
+ // - HIVE_TRANSPORT=ws forces WebSocket; HIVE_TRANSPORT=http forces long-poll.
10
+ // - Otherwise a WebSocket first. Only when it fails BEFORE opening, and the failure says a network
11
+ // is in the way (an HTTP answer instead of the upgrade -- "403 WebSocket upgrades are not
12
+ // allowed" on Antigravity's VMs -- or any failure while an HTTP(S) proxy is configured), is the
13
+ // relay's /health read over plain HTTPS. If it offers poll, this socket and every later one to
14
+ // that relay use long-poll. A relay that does not offer it gets the original error, unchanged.
15
+ // - A relay that refused for its own reasons (a bad token, a full room) refuses the poll session
16
+ // the same way, with the same status: there is no loop and no second opinion.
17
+ //
18
+ // Plain fetch carries the requests. On Node 24 with NODE_USE_ENV_PROXY=1 -- what Antigravity's VMs
19
+ // set -- fetch goes through HTTPS_PROXY and trusts NODE_EXTRA_CA_CERTS by itself.
20
+ import { WebSocket as WS } from 'ws'
21
+ import { POLL_PREFIX, POLL_VERSION, packFrames, unpackFrames } from './hive-poll-proto.js'
22
+
23
+ const CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
25
+ const decided = new Map() // http origin -> 'ws' | 'poll'
26
+ const probes = new Map() // http origin -> { at, offers: Promise<boolean> }
27
+ const reported = new Set()
28
+
29
+ export function httpOrigin(wsUrl) {
30
+ const u = new URL(wsUrl)
31
+ return `${u.protocol === 'wss:' || u.protocol === 'https:' ? 'https:' : 'http:'}//${u.host}`
32
+ }
33
+ export function transportMode(env = process.env) {
34
+ const m = String(env.HIVE_TRANSPORT || '').trim().toLowerCase()
35
+ if (m === 'http' || m === 'https' || m === 'poll') return 'poll'
36
+ if (m === 'ws' || m === 'websocket') return 'ws'
37
+ return 'auto'
38
+ }
39
+ /** Which pipe this process uses for a relay: 'ws', 'poll', or 'ws' until something says otherwise. */
40
+ export function transportFor(relay, env = process.env) {
41
+ try {
42
+ const m = transportMode(env)
43
+ if (m !== 'auto') return m
44
+ return decided.get(httpOrigin(relay)) || 'ws'
45
+ } catch { return 'ws' }
46
+ }
47
+ const proxyConfigured = (env = process.env) => !!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy)
48
+ function statusOf(ev) {
49
+ if (!ev) return null
50
+ if (typeof ev.status === 'number') return ev.status
51
+ const m = /Unexpected server response:\s*(\d{3})/.exec(String((ev && (ev.message || (ev.error && ev.error.message))) || ''))
52
+ return m ? Number(m[1]) : null
53
+ }
54
+
55
+ /** Does this relay offer long-poll? Read from /health over plain HTTPS; remembered for a minute. */
56
+ export function offersPoll(origin, fetchImpl = globalThis.fetch) {
57
+ const p = probes.get(origin)
58
+ if (p && Date.now() - p.at < 60000) return p.offers
59
+ const offers = (async () => {
60
+ try {
61
+ const ac = new AbortController()
62
+ const t = setTimeout(() => ac.abort(), 15000)
63
+ const r = await fetchImpl(origin + '/health', { signal: ac.signal, headers: { 'cache-control': 'no-store' } }).finally(() => clearTimeout(t))
64
+ if (!r.ok) return false
65
+ const j = await r.json()
66
+ return Number(j && j.poll) >= 1
67
+ } catch { return false }
68
+ })()
69
+ probes.set(origin, { at: Date.now(), offers })
70
+ return offers
71
+ }
72
+
73
+ function fire(target, type, ev) {
74
+ const h = target['on' + type]
75
+ if (typeof h === 'function') { try { h.call(target, ev) } catch (e) { if (process.env.HIVE_DEBUG) console.error('[hive-transport] handler threw:', e) } }
76
+ const ls = target._listeners && target._listeners[type]
77
+ if (ls) for (const fn of [...ls]) { try { fn.call(target, ev) } catch { } }
78
+ }
79
+ class SocketShape {
80
+ constructor() {
81
+ this.CONNECTING = CONNECTING; this.OPEN = OPEN; this.CLOSING = CLOSING; this.CLOSED = CLOSED
82
+ this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null
83
+ this._listeners = {}
84
+ }
85
+ addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn) }
86
+ removeEventListener(type, fn) { const l = this._listeners[type]; if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1) } }
87
+ }
88
+
89
+ /**
90
+ * One connection to the relay over HTTPS requests. A held recv brings the relay's frames the moment
91
+ * they exist; send posts the client's frames in order, numbered so a retry is never applied twice.
92
+ */
93
+ export class PollSocket extends SocketShape {
94
+ static CONNECTING = CONNECTING
95
+ static OPEN = OPEN
96
+ static CLOSING = CLOSING
97
+ static CLOSED = CLOSED
98
+ constructor(url, _protocols, opts = {}) {
99
+ super()
100
+ this.url = url
101
+ this.readyState = CONNECTING
102
+ this.binaryType = 'nodebuffer'
103
+ this._fetch = opts.fetch || globalThis.fetch
104
+ const u = new URL(url)
105
+ this._base = httpOrigin(url) + POLL_PREFIX
106
+ this._path = u.pathname + u.search
107
+ this._sid = null
108
+ this._ack = 0 // the last relay frame received
109
+ this._outSeq = 1 // the number of the next frame this client sends
110
+ this._outbox = []
111
+ this._sending = false
112
+ this._hold = 20000
113
+ this._troubleSince = 0
114
+ this._flushQueued = false
115
+ setImmediate(() => { this._open() })
116
+ }
117
+ _req(op, body, headers = {}, timeoutMs = 30000) {
118
+ const ac = new AbortController()
119
+ const t = setTimeout(() => ac.abort(), timeoutMs)
120
+ const url = this._base + (op === 'open' ? 'open' : `${this._sid}/${op}`)
121
+ return this._fetch(url, { method: 'POST', headers: { 'cache-control': 'no-store', ...headers }, body, signal: ac.signal }).finally(() => clearTimeout(t))
122
+ }
123
+ async _open() {
124
+ if (this.readyState !== CONNECTING) return
125
+ let r
126
+ try { r = await this._req('open', JSON.stringify({ path: this._path, v: POLL_VERSION }), { 'content-type': 'application/json' }, 30000) }
127
+ catch (e) { return this._fail(`could not reach the relay over HTTPS: ${(e && e.cause && e.cause.code) || (e && e.message) || e}`) }
128
+ let j = null
129
+ try { j = await r.json() } catch { }
130
+ if (r.status !== 200 || !j || !j.sid) {
131
+ // Worded like the ws library's handshake error, so statusFromError and every message built on
132
+ // it read a refused session exactly as they read a refused upgrade.
133
+ return this._fail(`Unexpected server response: ${r.status}${j && j.line ? ` (${j.line})` : ''}`, r.status)
134
+ }
135
+ this._sid = j.sid
136
+ this._hold = Math.max(2000, Math.min(Number(j.holdMs) || 20000, 25000))
137
+ if (this.readyState !== CONNECTING) { this._req('close', undefined, {}, 5000).catch(() => { }); return }
138
+ this.readyState = OPEN
139
+ fire(this, 'open', { type: 'open', target: this })
140
+ this._recvLoop()
141
+ this._flush()
142
+ }
143
+ _fail(message, status) {
144
+ if (this.readyState === CLOSED) return
145
+ this.readyState = CLOSED
146
+ const error = Object.assign(new Error(message), status ? { status } : {})
147
+ fire(this, 'error', { type: 'error', message, error, status, target: this })
148
+ fire(this, 'close', { type: 'close', code: 1006, reason: message, wasClean: false, target: this })
149
+ }
150
+ _lost(reason, code = 1006) {
151
+ if (this.readyState === CLOSED) return
152
+ this.readyState = CLOSED
153
+ if (this._sid) this._req('close', undefined, {}, 5000).catch(() => { })
154
+ fire(this, 'close', { type: 'close', code, reason, wasClean: false, target: this })
155
+ }
156
+ /** The relay ended the session: say why, when it said (a revoked token, a deleted room). */
157
+ async _gone(r) {
158
+ let j = null
159
+ try { j = await r.json() } catch { }
160
+ return this._lost((j && j.reason) || 'the relay ended this session', (j && Number(j.code)) || 1006)
161
+ }
162
+ /** Bytes not yet accepted by the relay, the in-flight batch included (as a WebSocket counts them). */
163
+ get bufferedAmount() { let n = 0; for (const b of this._outbox) n += b.length; return n }
164
+ /** A request failed. Keep trying for 45 s (the relay keeps the session that long), then give up. */
165
+ async _trouble(e, startedAt) {
166
+ const now = Date.now()
167
+ if (!this._troubleSince) this._troubleSince = now
168
+ if (now - this._troubleSince > 45000) { this._lost(`the relay stopped answering over HTTPS: ${(e && e.message) || e}`); return false }
169
+ // A proxy that cuts long requests shows up as a failure after a long wait: hold shorter.
170
+ if (startedAt && now - startedAt > 5000 && this._hold > 4000) this._hold = Math.max(4000, Math.floor(this._hold / 2))
171
+ await sleep(Math.min(2000, 250 * 2 ** Math.min(3, Math.floor((now - this._troubleSince) / 5000))))
172
+ return this.readyState === OPEN
173
+ }
174
+ _ok() { this._troubleSince = 0 }
175
+ async _recvLoop() {
176
+ while (this.readyState === OPEN) {
177
+ const started = Date.now()
178
+ let r
179
+ try { r = await this._req('recv', undefined, { 'x-hive-ack': String(this._ack), 'x-hive-hold': String(this._hold) }, this._hold + 15000) }
180
+ catch (e) { if (!(await this._trouble(e, started))) return; continue }
181
+ if (this.readyState !== OPEN) return
182
+ if (r.status === 404 || r.status === 410) return this._gone(r)
183
+ if (r.status === 204) { this._ok(); continue }
184
+ if (r.status !== 200) { await r.arrayBuffer().catch(() => { }); if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return; continue }
185
+ let frames, first
186
+ try { first = Number(r.headers.get('x-hive-seq')); frames = unpackFrames(Buffer.from(await r.arrayBuffer())) }
187
+ catch (e) { if (!(await this._trouble(e, started))) return; continue } // asked again: unacked frames come back
188
+ this._ok()
189
+ for (let i = 0; i < frames.length; i++) {
190
+ const seq = first + i
191
+ if (seq <= this._ack) continue // already delivered (a resend after a lost answer)
192
+ if (seq !== this._ack + 1) return this._lost(`a gap in the relay's messages (${this._ack}, then ${seq})`)
193
+ this._ack = seq
194
+ const f = frames[i]
195
+ const data = this.binaryType === 'arraybuffer' ? f.buffer.slice(f.byteOffset, f.byteOffset + f.byteLength) : Buffer.from(f)
196
+ fire(this, 'message', { type: 'message', data, target: this })
197
+ if (this.readyState !== OPEN) return
198
+ }
199
+ }
200
+ }
201
+ send(data) {
202
+ if (this.readyState === CONNECTING) throw new Error('WebSocket is not open: readyState 0 (CONNECTING)')
203
+ if (this.readyState !== OPEN) return
204
+ const b = data instanceof ArrayBuffer ? Buffer.from(new Uint8Array(data)) : Buffer.from(data.buffer ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : data)
205
+ this._outbox.push(Buffer.from(b)) // owned: the caller may reuse its buffer
206
+ if (!this._flushQueued) { this._flushQueued = true; setImmediate(() => { this._flushQueued = false; this._flush() }) }
207
+ }
208
+ async _flush() {
209
+ if (this._sending || !this._outbox.length || this.readyState !== OPEN) return
210
+ this._sending = true
211
+ try {
212
+ while (this._outbox.length && this.readyState === OPEN) {
213
+ let n = 0, bytes = 0
214
+ while (n < this._outbox.length && n < 512 && (n === 0 || bytes + this._outbox[n].length <= 4 * 1048576)) { bytes += this._outbox[n].length; n++ }
215
+ const batch = this._outbox.slice(0, n)
216
+ const first = this._outSeq
217
+ const body = packFrames(batch)
218
+ for (;;) {
219
+ if (this.readyState !== OPEN) return
220
+ const started = Date.now()
221
+ let r
222
+ try { r = await this._req('send', body, { 'content-type': 'application/octet-stream', 'x-hive-seq': String(first) }, 60000) }
223
+ catch (e) { if (!(await this._trouble(e, 0))) return; continue } // the same numbers again: never applied twice
224
+ if (r.status === 404 || r.status === 410) return this._gone(r)
225
+ await r.arrayBuffer().catch(() => { })
226
+ if (r.status === 204 || r.status === 200) break
227
+ if (r.status === 409) return this._lost("the relay saw a gap in this client's messages")
228
+ if (r.status === 400 || r.status === 413) return this._lost(`the relay refused a message (HTTP ${r.status})`)
229
+ if (!(await this._trouble(new Error(`HTTP ${r.status}`), started))) return
230
+ }
231
+ this._ok()
232
+ this._outbox.splice(0, n)
233
+ this._outSeq = first + n
234
+ }
235
+ } finally { this._sending = false }
236
+ }
237
+ close(code = 1000, reason = '') {
238
+ if (this.readyState === CLOSING || this.readyState === CLOSED) return
239
+ const had = !!this._sid
240
+ this.readyState = CLOSED
241
+ if (had) this._req('close', undefined, {}, 5000).catch(() => { })
242
+ setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
243
+ }
244
+ terminate() { this.close(1006, 'terminated') }
245
+ }
246
+
247
+ /**
248
+ * The socket every provider gets: a WebSocket, or long-poll where WebSockets are refused (above).
249
+ */
250
+ export class HiveSocket extends SocketShape {
251
+ static CONNECTING = CONNECTING
252
+ static OPEN = OPEN
253
+ static CLOSING = CLOSING
254
+ static CLOSED = CLOSED
255
+ constructor(url, protocols) {
256
+ super()
257
+ this.url = url
258
+ this.protocols = protocols
259
+ this.readyState = CONNECTING
260
+ this._bt = 'nodebuffer'
261
+ this._inner = null
262
+ this._closedByUser = false
263
+ let origin = ''
264
+ try { origin = httpOrigin(url) } catch { }
265
+ this._origin = origin
266
+ const mode = transportMode()
267
+ const pick = mode !== 'auto' ? mode : (decided.get(origin) || 'ws')
268
+ this._attach(pick === 'poll' ? new PollSocket(url, protocols) : this._makeWS(url, protocols), pick)
269
+ }
270
+ /**
271
+ * A WebSocket that remembers whether a refusal came from the RELAY (it marks its own with
272
+ * X-Hive-Relay) or from something in the way. Antigravity's review, 7 Nov: without this, a bad token
273
+ * or a full room printed "this network refused a WebSocket" and opened a poll session to be refused
274
+ * again. The error and close that follow are the ones ws itself would emit.
275
+ */
276
+ _makeWS(url, protocols) {
277
+ const ws = new WS(url, protocols)
278
+ ws.on('unexpected-response', (req, res) => {
279
+ this._relayRefused = !!(res.headers && res.headers['x-hive-relay'])
280
+ res.resume()
281
+ req.destroy(new Error(`Unexpected server response: ${res.statusCode}`))
282
+ })
283
+ return ws
284
+ }
285
+ get binaryType() { return this._bt }
286
+ set binaryType(v) { this._bt = v; if (this._inner) this._inner.binaryType = v }
287
+ get transport() { return this._kind }
288
+ get bufferedAmount() { return (this._inner && this._inner.bufferedAmount) || 0 }
289
+ _attach(inner, kind) {
290
+ this._inner = inner
291
+ this._kind = kind
292
+ this._early = null
293
+ inner.binaryType = this._bt
294
+ inner.onopen = (ev) => {
295
+ if (this._inner !== inner) return
296
+ this.readyState = OPEN
297
+ if (kind === 'poll' && this._origin && transportMode() === 'auto') decided.set(this._origin, 'poll')
298
+ fire(this, 'open', ev)
299
+ }
300
+ inner.onmessage = (ev) => { if (this._inner === inner) fire(this, 'message', ev) }
301
+ inner.onerror = (ev) => {
302
+ if (this._inner !== inner) return
303
+ if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._early = ev; return }
304
+ fire(this, 'error', ev)
305
+ }
306
+ inner.onclose = (ev) => {
307
+ if (this._inner !== inner) return
308
+ if (this.readyState === CONNECTING && kind === 'ws' && transportMode() === 'auto') { this._fallBackOrFail(ev); return }
309
+ this.readyState = CLOSED
310
+ fire(this, 'close', ev)
311
+ }
312
+ }
313
+ async _fallBackOrFail(closeEv) {
314
+ const early = this._early
315
+ const status = statusOf(early)
316
+ const networkInTheWay = !this._relayRefused && (status != null || proxyConfigured())
317
+ let offers = false
318
+ if (networkInTheWay && this._origin && !this._closedByUser) {
319
+ this._probing = true
320
+ try { offers = await offersPoll(this._origin) } finally { this._probing = false }
321
+ }
322
+ if (this._closedByUser) return // close() already settled it (below)
323
+ if (offers) {
324
+ if (!reported.has(this._origin)) {
325
+ reported.add(this._origin)
326
+ console.error(`[hivecode] this network refused a WebSocket to ${this._origin}${status ? ` (HTTP ${status})` : ''}; using HTTPS long-poll instead. Everything works the same, a little slower.`)
327
+ }
328
+ this._attach(new PollSocket(this.url, this.protocols), 'poll')
329
+ return
330
+ }
331
+ this.readyState = CLOSED
332
+ if (early) fire(this, 'error', early)
333
+ fire(this, 'close', closeEv)
334
+ }
335
+ send(data) { return this._inner.send(data) }
336
+ close(code = 1000, reason = '') {
337
+ this._closedByUser = true
338
+ if (this._probing || (this.readyState === CONNECTING && !this._inner)) {
339
+ // Mid-switch the WebSocket is already gone and no poll socket exists yet: settle it here,
340
+ // once, as a WebSocket closed while connecting would.
341
+ if (this.readyState === CLOSED) return
342
+ this.readyState = CLOSED
343
+ setImmediate(() => fire(this, 'close', { type: 'close', code, reason, wasClean: true, target: this }))
344
+ return
345
+ }
346
+ try { this._inner.close(code, reason) } catch { }
347
+ }
348
+ terminate() {
349
+ if (this._probing) return this.close(1006, 'terminated')
350
+ this._closedByUser = true
351
+ try { this._inner.terminate ? this._inner.terminate() : this._inner.close() } catch { }
352
+ }
353
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,10 +31,12 @@
31
31
  "hive-session.js",
32
32
  "hive-mux.js",
33
33
  "hive-mux-proto.js",
34
+ "hive-transport.js",
35
+ "hive-poll-proto.js",
34
36
  "hive-version.js",
35
37
  "hive-pr-staleness.js",
36
38
  "hive-pr.js",
37
- "hive-branch.js",
39
+ "hive-branch.js", "hive-land.js", "hive-repos.js", "hive-room-mode.js", "hive-room-watcher.js",
38
40
  "hive-mailbox.js",
39
41
  "hive-obligations.js",
40
42
  "hive-receipts.js",
@@ -67,6 +69,7 @@
67
69
  "lang-toml.js",
68
70
  "icr-merge.js",
69
71
  "hive-coord.js",
72
+ "hive-authors.js",
70
73
  "hive-gate.js",
71
74
  "substrate.js",
72
75
  "HIVE_RULES.md",