hivecode-mcp 0.13.1 → 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.
- package/hive-await.js +23 -5
- package/hive-mcp.js +3 -0
- package/hive-poll-proto.js +50 -0
- package/hive-transport.js +353 -0
- package/package.json +3 -1
- package/sync.js +4 -2
package/hive-await.js
CHANGED
|
@@ -165,16 +165,34 @@ if (process.env.HIVE_AWAIT_KEEP_ALIVE !== '1' && PARENT > 1 && !parentGone()) {
|
|
|
165
165
|
}, Number(process.env.HIVE_AWAIT_PARENT_CHECK_MS) || 15000).unref()
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// A REBUILT ROOM MUST NOT LEAVE THE LISTENER DEAF (7 Nov 2026, found by antigravity-2). A relay
|
|
169
|
+
// that restarts without its rooms (every deploy on Render's free plan) starts chat again from zero,
|
|
170
|
+
// but the cursor on disk still said, say, 300: scanning from 300 of a 3-message chat found nothing,
|
|
171
|
+
// so the agent showed LISTENING and no mention woke it until the idle timeout, which then moved the
|
|
172
|
+
// cursor past everything it had missed. So the cursor records which room it was counted in (its
|
|
173
|
+
// first message) and is ignored when that differs, or when it points past the end -- the rule the
|
|
174
|
+
// room's cursor above and the MCP's catch-up already follow. Chat is never trimmed, so within one
|
|
175
|
+
// room the first message never changes. An older cursor file without `head` is only clamped.
|
|
176
|
+
// (tests/await-rebuilt-room-test.mjs)
|
|
168
177
|
const cursorFile = path.join(dir, `.await-cursor-${room}.json`)
|
|
169
|
-
const
|
|
170
|
-
const
|
|
178
|
+
const headOf = (msgs) => { const m = msgs[0]; return m ? `${m.by}|${m.at}|${String(m.text || '').slice(0, 80)}` : '' }
|
|
179
|
+
const readCursor = (msgs) => {
|
|
180
|
+
try {
|
|
181
|
+
const c = JSON.parse(fs.readFileSync(cursorFile, 'utf8'))
|
|
182
|
+
const at = Number(c.at) || 0
|
|
183
|
+
if (at > msgs.length) return 0
|
|
184
|
+
if (typeof c.head === 'string' && c.head && c.head !== headOf(msgs)) return 0
|
|
185
|
+
return at
|
|
186
|
+
} catch { return 0 }
|
|
187
|
+
}
|
|
188
|
+
const writeCursor = (n, msgs) => { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cursorFile, JSON.stringify({ at: n, head: headOf(msgs) }), 'utf8') } catch { /* a cursor we cannot persist must not break the wait */ } }
|
|
171
189
|
|
|
172
190
|
function check() {
|
|
173
191
|
if (superseded()) return // the newer listener delivers it; consuming it here would hide it from that one
|
|
174
192
|
const msgs = chat.toArray()
|
|
175
193
|
let lastMine = -1
|
|
176
194
|
for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].by === NAME) { lastMine = i; break } }
|
|
177
|
-
const from = Math.max(readCursor(), lastMine + 1, roomCursor(msgs.length))
|
|
195
|
+
const from = Math.max(readCursor(msgs), lastMine + 1, roomCursor(msgs.length))
|
|
178
196
|
const hits = []
|
|
179
197
|
for (let i = from; i < msgs.length; i++) {
|
|
180
198
|
const m = msgs[i]
|
|
@@ -183,7 +201,7 @@ function check() {
|
|
|
183
201
|
// Advance past everything READ, not just everything matched — an unaddressed message I have
|
|
184
202
|
// already seen must not be re-scanned on the next call either.
|
|
185
203
|
if (hits.length) {
|
|
186
|
-
writeCursor(msgs.length)
|
|
204
|
+
writeCursor(msgs.length, msgs)
|
|
187
205
|
publish(msgs.length)
|
|
188
206
|
// THE ORDER TO START AGAIN RIDES ON EVERY WAKE. It used to be said once, on the agent's first
|
|
189
207
|
// quiet hive_wait, and an agent had to remember it after every wake; agents forget (4 Oct 2026).
|
|
@@ -205,7 +223,7 @@ hive.provider.on('sync', (s) => {
|
|
|
205
223
|
// An idle timeout also advances the cursor. Without this, a quiet window followed by a loud one
|
|
206
224
|
// would re-deliver everything that arrived during the quiet window too.
|
|
207
225
|
setTimeout(() => {
|
|
208
|
-
try { writeCursor(chat.length) } catch {}
|
|
226
|
+
try { writeCursor(chat.length, chat.toArray()) } catch {}
|
|
209
227
|
publish(chat.length) // nothing in the window named us, so nothing in it is mail the MCP should raise
|
|
210
228
|
finish('idle', { note: `nothing addressed to ${NAME} in ${IDLE_MIN} min. Run this same command again in the background to keep listening: ${AGAIN}` }, 1500)
|
|
211
229
|
}, Number(IDLE_MIN) * 60 * 1000)
|
package/hive-mcp.js
CHANGED
|
@@ -36,6 +36,7 @@ import { startUpdateCheck, updateNotice } from './hive-update.js'
|
|
|
36
36
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
37
37
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
38
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
|
+
import { transportFor } from './hive-transport.js'
|
|
39
40
|
import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
40
41
|
import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
|
|
41
42
|
import { isSecuredRoom } from './token.js'
|
|
@@ -1799,6 +1800,8 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1799
1800
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
1800
1801
|
connected: conn ? conn.connected : null,
|
|
1801
1802
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1803
|
+
// 'ws', or 'poll' where this network refuses WebSockets (HTTPS long-poll: same rooms, same rules).
|
|
1804
|
+
transport: conn ? transportFor(s.relay) : null,
|
|
1802
1805
|
...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
|
|
1803
1806
|
...(conn && conn.unsharedFiles && conn.unsharedFiles.length ? { unshared: conn.unsharedFiles } : {}),
|
|
1804
1807
|
...(conn && conn.unwritable && conn.unwritable.length ? { unwritable: { files: conn.unwritable.map((u) => `${u.path} (${u.code})`), meaning: 'read-only or locked in your folder, so your copy is older than the room\'s; each is written as soon as it can be' } } : {}),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// hive-poll-proto.js -- the wire format of the HTTPS long-poll transport (relay-poll.js on the
|
|
2
|
+
// relay, hive-transport.js in clients). It exists for networks that refuse WebSocket upgrades but
|
|
3
|
+
// pass ordinary HTTPS: Antigravity's cloud VMs (their egress proxy answers "403 WebSocket upgrades
|
|
4
|
+
// are not allowed", 7 Nov 2026) and many company proxies.
|
|
5
|
+
//
|
|
6
|
+
// It carries EXACTLY the bytes a WebSocket would: each message is one frame, in order, so every
|
|
7
|
+
// gate on the relay (claims, read-only, the governor, rate limits) sees what it always sees.
|
|
8
|
+
//
|
|
9
|
+
// POST /_hive_poll/open JSON {path, v} -> 200 {sid, holdMs, idleMs, v} | the refusal an upgrade would get
|
|
10
|
+
// POST /_hive_poll/<sid>/send frames, header x-hive-seq: the first frame's number (1, 2, 3, ...)
|
|
11
|
+
// -> 204 once delivered. A retry of the same numbers is ignored, so a
|
|
12
|
+
// lost answer never applies a message twice; a gap is a 409.
|
|
13
|
+
// POST /_hive_poll/<sid>/recv header x-hive-ack: the last frame received, x-hive-hold: ms to wait
|
|
14
|
+
// -> 200 frames (x-hive-seq: the first one's number) | 204 nothing yet
|
|
15
|
+
// Frames up to the ack are forgotten; the rest are sent again, so a
|
|
16
|
+
// lost answer loses nothing.
|
|
17
|
+
// POST /_hive_poll/<sid>/close -> 204
|
|
18
|
+
// An unknown or ended session answers 404 {gone:true}: the client reconnects and resyncs.
|
|
19
|
+
//
|
|
20
|
+
// The room, the token and the name travel in the open request's BODY, never in a URL: a proxy logs
|
|
21
|
+
// URLs, and a room id is the key to the room.
|
|
22
|
+
export const POLL_PREFIX = '/_hive_poll/'
|
|
23
|
+
export const POLL_VERSION = 1
|
|
24
|
+
|
|
25
|
+
/** Frames to one buffer: for each, a 4-byte big-endian length, then its bytes. */
|
|
26
|
+
export function packFrames(frames) {
|
|
27
|
+
let total = 0
|
|
28
|
+
for (const f of frames) total += 4 + f.length
|
|
29
|
+
const out = Buffer.allocUnsafe(total)
|
|
30
|
+
let o = 0
|
|
31
|
+
for (const f of frames) {
|
|
32
|
+
out.writeUInt32BE(f.length, o); o += 4
|
|
33
|
+
Buffer.from(f.buffer, f.byteOffset, f.length).copy(out, o); o += f.length
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One buffer back to its frames. Throws on anything malformed, so nothing half-read is delivered. */
|
|
39
|
+
export function unpackFrames(buf) {
|
|
40
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
|
|
41
|
+
const out = []
|
|
42
|
+
let o = 0
|
|
43
|
+
while (o < b.length) {
|
|
44
|
+
if (o + 4 > b.length) throw new Error('truncated frame length')
|
|
45
|
+
const n = b.readUInt32BE(o); o += 4
|
|
46
|
+
if (o + n > b.length) throw new Error('truncated frame')
|
|
47
|
+
out.push(b.subarray(o, o + n)); o += n
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
@@ -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.
|
|
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,6 +31,8 @@
|
|
|
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",
|
package/sync.js
CHANGED
|
@@ -22,7 +22,7 @@ import crypto from 'crypto'
|
|
|
22
22
|
import * as Y from 'yjs'
|
|
23
23
|
import { WebsocketProvider } from 'y-websocket'
|
|
24
24
|
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
25
|
-
import {
|
|
25
|
+
import { HiveSocket, transportFor } from './hive-transport.js' // a WebSocket, or HTTPS long-poll where WebSockets are refused
|
|
26
26
|
import ignore from 'ignore'
|
|
27
27
|
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
28
28
|
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
@@ -1121,7 +1121,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1121
1121
|
if (name) wsParams.name = String(name).slice(0, 48)
|
|
1122
1122
|
if (kind) wsParams.kind = kind
|
|
1123
1123
|
if (passive) wsParams.passive = 'true'
|
|
1124
|
-
|
|
1124
|
+
// HiveSocket: a WebSocket where the network allows one, HTTPS long-poll where it refuses the upgrade
|
|
1125
|
+
// (Antigravity's cloud VMs, many company proxies). Same bytes, same relay gates, either way.
|
|
1126
|
+
const wsOpts = { WebSocketPolyfill: HiveSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
|
|
1125
1127
|
// ONE SOCKET PER PARTICIPANT. When the relay offers it (it says so in /health), file docs stop
|
|
1126
1128
|
// costing a socket each: they ride one physical socket as channels, and the relay admits each
|
|
1127
1129
|
// one through the same gates a per-file socket passes. Against a relay that does not offer it,
|