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/hive-mcp.js CHANGED
@@ -20,12 +20,14 @@
20
20
  // it returns (read chat + board before editing, announce intent, prefer patches).
21
21
 
22
22
  import fs from 'fs'
23
+ import os from 'os'
23
24
  import path from 'path'
24
25
  import crypto from 'crypto'
25
26
  import { fileURLToPath } from 'url'
26
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
27
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
28
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
27
+ // The stdio server is our own, not @modelcontextprotocol/sdk's: the SDK drags express, hono, zod
28
+ // and ~85 more packages into every install for a slice this small. See hive-stdio.js.
29
+ import { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } from './hive-stdio.js'
30
+ import { startUpdateCheck, updateNotice } from './hive-update.js'
29
31
  // Substrate defaults for MCP agents: signed provenance + the silent-fork gate are ON
30
32
  // unless explicitly disabled. Agents register this server with arbitrary (often empty)
31
33
  // env, so relying on per-registration env vars left rooms in mixed signed/unsigned mode —
@@ -34,7 +36,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
36
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
37
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
38
  import { startSync, parseLink } from './sync.js'
37
- import { checkRelay, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
39
+ import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
38
40
  import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
39
41
  import { INSTRUCTIONS } from './hive-instructions.js'
40
42
  import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
@@ -63,7 +65,16 @@ const text = (s) => {
63
65
  }
64
66
  const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
65
67
 
68
+ // Every route to a relay passes through here, so a retired public relay is followed to its
69
+ // replacement in one place -- whether it arrived in a pasted link, a .hive.json written before
70
+ // the move, or a HIVE_RELAY our own docs once told people to set. See currentRelayFor().
66
71
  function resolveRoom(link, dir) {
72
+ const r = resolveRoomRaw(link, dir)
73
+ const { relay, migratedFrom } = currentRelayFor(r.relay)
74
+ return { ...r, relay, migratedFrom }
75
+ }
76
+
77
+ function resolveRoomRaw(link, dir) {
67
78
  if (link) { const { relay, room, token } = parseLink(link); return { relay: relay || DEFAULT_RELAY, room, token, mode: 'joined (link)' } }
68
79
  const cfgPath = path.join(path.resolve(dir), '.hive.json')
69
80
  if (fs.existsSync(cfgPath)) {
@@ -105,7 +116,49 @@ async function tryResumeSession() {
105
116
  } catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
106
117
  }
107
118
 
108
- async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
119
+ // WHERE AN AGENT'S FILES GO WHEN NOBODY SAID. This was './workspace', resolved against the
120
+ // directory the agent app happened to start this server in: the user's project for Claude Code,
121
+ // something unwritable or unguessable for others -- and the SAME folder for two agents started
122
+ // alike, which is the one thing a room must never have (one folder per participant). Now it is
123
+ // ~/hivecode/<room>/<name>: absolute, writable, and different for every name.
124
+ //
125
+ // One exception keeps returning users whole: an agent that joined the old way and now calls
126
+ // hive_join with nothing, expecting its room back from ./workspace/.hive.json, still gets it.
127
+ // Sending it to a new folder instead would host a brand-new room and report success -- the agent
128
+ // would be alone, told it was joined.
129
+ const HIVE_HOME = process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
130
+ const LEGACY_DIR = './workspace'
131
+ function safeSegment(s) {
132
+ const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
133
+ return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
134
+ }
135
+ function roomIn(dir) {
136
+ try { return JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.hive.json'), 'utf8')).room || null } catch { return null }
137
+ }
138
+ function defaultJoinTarget(link, name) {
139
+ // The folders this agent already has a room in: the one its last session used (hive_leave
140
+ // deletes that record, so it only survives a disconnect or a restart), then the old default.
141
+ const prev = readSessionFile()
142
+ const prevDir = prev && prev.dir && roomIn(prev.dir) ? prev.dir : null
143
+ const legacy = roomIn(LEGACY_DIR) ? LEGACY_DIR : null
144
+ if (!link) {
145
+ // "No link, no folder" has always meant "my room, if I have one". Hosting a new one instead
146
+ // would leave an agent that meant to rejoin alone in an empty room, told it had joined.
147
+ const back = prevDir || legacy
148
+ if (back) return { dir: back, link, hostedHere: false }
149
+ const room = 'room-' + crypto.randomBytes(13).toString('base64url')
150
+ return { dir: path.join(HIVE_HOME, room, safeSegment(name)), link: `${DEFAULT_RELAY}|${room}`, hostedHere: true }
151
+ }
152
+ const room = parseLink(link).room
153
+ if (!room) return { dir: LEGACY_DIR, link, hostedHere: false }
154
+ // Rejoining a room this agent was already in lands in the folder it used, files and all. The
155
+ // session record must be under THIS name: two runtimes started in one directory share it.
156
+ if (prevDir && prev.name === name && roomIn(prevDir) === room) return { dir: prevDir, link, hostedHere: false }
157
+ if (legacy && roomIn(legacy) === room) return { dir: legacy, link, hostedHere: false }
158
+ return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
159
+ }
160
+
161
+ async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
109
162
  // REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
110
163
  // exists, so validating after that point would leave an agent with a bad name holding nothing at
111
164
  // all -- it would lose a working room to a refusal it could have been given for free.
@@ -118,9 +171,12 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
118
171
  This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
119
172
  }
120
173
  if (session) { try { session.hive.stop() } catch {} ; session = null }
174
+ let hostedHere = false
175
+ if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
121
176
  const ROOT = path.resolve(dir)
122
177
  fs.mkdirSync(ROOT, { recursive: true })
123
- const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
178
+ const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
179
+ const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
124
180
  // THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
125
181
  // .hive.json write below and before startSync opens a socket. A refused join must leave
126
182
  // nothing behind -- no config pointing at the wrong relay for the next process to find,
@@ -143,7 +199,13 @@ was not written. Fix the relay and call hive_join again.`
143
199
  const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
144
200
  // persist the token too, so a re-join from .hive.json keeps the grant (the
145
201
  // invitee pastes the link once; subsequent joins just read the folder config).
146
- fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
202
+ // pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
203
+ // kept in .hive.json -- one of the two places readPin() looks -- was erased by the very first
204
+ // join that honoured it. Only the pin is carried over: an old token must not follow the
205
+ // folder into a different room.
206
+ let keep = {}
207
+ try { const old = JSON.parse(fs.readFileSync(path.join(ROOT, '.hive.json'), 'utf8')); if (old && old.pinnedRelay) keep = { pinnedRelay: old.pinnedRelay } } catch { /* none yet */ }
208
+ fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}), ...keep }, null, 2))
147
209
  // The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
148
210
  // asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
149
211
  // and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
@@ -331,7 +393,13 @@ was not written. Fix the relay and call hive_join again.`
331
393
  // bracketed verdict is what makes "did my source just leave the building?" answerable
332
394
  // without asking, and the warning line below is what makes it actionable.
333
395
  const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
334
- return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
396
+ // Said out loud, because changing where someone's files go without telling them is the thing
397
+ // this whole module exists to prevent -- even when the move is to where they meant to be.
398
+ const movedNote = migratedFrom
399
+ ? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
400
+ `It now uses ${relay}, and .hive.json has been updated, so this happens once.`
401
+ : ''
402
+ return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${movedNote}${privNote}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.${updateNotice() ? '\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
335
403
  }
336
404
 
337
405
  function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
@@ -473,7 +541,7 @@ function waitForWork(timeoutMs, opts = {}) {
473
541
 
474
542
  const TOOLS = [
475
543
  { name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
476
- inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
544
+ inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync. Leave it out and Hivecode uses ~/hivecode/<room>/<your name>, a folder of your own. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
477
545
  { name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
478
546
  inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
479
547
  { name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
@@ -1318,7 +1386,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1318
1386
  // answer to the second -- the public relay and a company's own are the same shape.
1319
1387
  const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
1320
1388
  return text({
1321
- room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
1389
+ room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
1322
1390
  privacy: pv.headline,
1323
1391
  ...(pv.warn ? { privacyWarning: pv.warn } : {}),
1324
1392
  ...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
@@ -1470,10 +1538,37 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1470
1538
 
1471
1539
  // Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
1472
1540
  // hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
1473
- const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
1474
- if (isEntryPoint) {
1541
+ //
1542
+ // COMPARED AS REAL PATHS. On macOS and Linux, npx starts a package's bin through the symlink in
1543
+ // node_modules/.bin. Node resolves that symlink for import.meta.url and NOT for argv[1], so the
1544
+ // plain comparison this used to make was false there: the server never started, the process
1545
+ // exited 0 with no output, and the agent app showed nothing but "failed to start". Reproduced on
1546
+ // 25 Sep 2026 through a Windows junction, which Node treats the same way.
1547
+ function isMainModule() {
1548
+ if (!process.argv[1]) return false
1549
+ const me = fileURLToPath(import.meta.url)
1550
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
1551
+ }
1552
+ const isEntryPoint = isMainModule()
1553
+ if (isEntryPoint && process.argv[2] === 'setup') {
1554
+ // `npx -y hivecode-mcp@latest setup` -- configures every agent app on this machine and checks
1555
+ // each one starts. It never opens stdio: nobody is speaking JSON-RPC to a setup run.
1556
+ const { main } = await import('./hive-setup.js')
1557
+ process.exit(await main(process.argv.slice(3)))
1558
+ } else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
1559
+ console.log(VERSION)
1560
+ process.exit(0)
1561
+ } else if (isEntryPoint) {
1475
1562
  const transport = new StdioServerTransport()
1563
+ // STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
1564
+ // room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
1565
+ // and a presence entry for a member that was gone, until something killed it.
1566
+ server.onclose = () => {
1567
+ try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
1568
+ setTimeout(() => process.exit(0), 500)
1569
+ }
1476
1570
  await server.connect(transport)
1571
+ startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
1477
1572
  // stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
1478
1573
  console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
1479
1574
  }
@@ -1481,6 +1576,7 @@ if (isEntryPoint) {
1481
1576
  /** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
1482
1577
  export const _test = {
1483
1578
  waitForWork,
1579
+ defaultJoinTarget,
1484
1580
  mailbox,
1485
1581
  // Exposed so the throttle's saving is asserted against the counter itself rather than inferred
1486
1582
  // from what happened not to appear in a tool result. Counting absences is how you prove a thing
@@ -0,0 +1,58 @@
1
+ // hive-mux-proto.js -- the wire format for ONE socket per participant, shared by relay and client.
2
+ //
3
+ // WHY: Hivecode opened one WebSocket per FILE. A participant held 1 + min(files, 128) sockets,
4
+ // so an agent in a 1000-file repository saw only 128 files on disk, and a relay's connection cap
5
+ // divided by 129 per person. The mux carries every file doc of one room over one socket.
6
+ //
7
+ // The design rule that makes it safe: the relay does not grow a second enforcement path. Each
8
+ // OPEN runs the SAME per-doc admission a per-file upgrade runs (token scope for that path, the
9
+ // per-room ceiling, the committer limit), and the accepted channel is wrapped in a VirtualConn
10
+ // that looks exactly like a ws connection to installUsageLimits, makeReadOnly, installWriteGate
11
+ // and setupWSConnection. The bytes inside a MSG frame are the same y-protocols message a
12
+ // per-file socket would carry.
13
+ //
14
+ // Frames are binary, lib0-encoded:
15
+ // client -> relay OPEN [0, id, docName] open this file doc as channel `id`
16
+ // CLOSE [1, id] the client is done with it
17
+ // MSG [2, id, ...payload] a y-protocols message for that doc
18
+ // relay -> client MSG [2, id, ...payload]
19
+ // OPENED [3, id] admitted; the relay's sync step 1 follows
20
+ // REFUSED [4, id, code, reason] not admitted -- the HTTP status a per-file
21
+ // upgrade would have answered, and why
22
+ // CLOSED [5, id, reason] the relay closed that channel
23
+ // BEAT [6, 0] liveness, so a dead socket is noticed
24
+ // Channel ids are chosen by the client and are only meaningful on the socket that opened them.
25
+ import * as encoding from 'lib0/encoding'
26
+ import * as decoding from 'lib0/decoding'
27
+
28
+ export const MUX_PREFIX = '_hive_mux/'
29
+ export const MUX_VERSION = 1
30
+ export const F = Object.freeze({ OPEN: 0, CLOSE: 1, MSG: 2, OPENED: 3, REFUSED: 4, CLOSED: 5, BEAT: 6 })
31
+
32
+ export function encodeFrame(kind, id, a, b) {
33
+ const e = encoding.createEncoder()
34
+ encoding.writeVarUint(e, kind)
35
+ encoding.writeVarUint(e, id >>> 0)
36
+ if (kind === F.OPEN) encoding.writeVarString(e, String(a))
37
+ else if (kind === F.MSG) encoding.writeUint8Array(e, a instanceof Uint8Array ? a : new Uint8Array(a))
38
+ else if (kind === F.REFUSED) { encoding.writeVarUint(e, a >>> 0); encoding.writeVarString(e, String(b || '')) }
39
+ else if (kind === F.CLOSED) encoding.writeVarString(e, String(a || ''))
40
+ return encoding.toUint8Array(e)
41
+ }
42
+
43
+ /** Decode one frame. Throws on garbage, which the caller treats as a protocol violation. */
44
+ export function decodeFrame(buf) {
45
+ const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
46
+ const d = decoding.createDecoder(u8)
47
+ const kind = decoding.readVarUint(d)
48
+ const id = decoding.readVarUint(d)
49
+ if (kind === F.OPEN) return { kind, id, docName: decoding.readVarString(d) }
50
+ if (kind === F.MSG) return { kind, id, payload: u8.subarray(d.pos) }
51
+ if (kind === F.REFUSED) return { kind, id, code: decoding.readVarUint(d), reason: decoding.readVarString(d) }
52
+ if (kind === F.CLOSED) return { kind, id, reason: decoding.readVarString(d) }
53
+ if (kind === F.CLOSE || kind === F.OPENED || kind === F.BEAT) return { kind, id }
54
+ throw new Error(`unknown mux frame kind ${kind}`)
55
+ }
56
+
57
+ /** The mux upgrade path for a base room, as the relay parses it (pathname minus the slash). */
58
+ export function muxPath(room) { return MUX_PREFIX + encodeURIComponent(room) }
package/hive-mux.js ADDED
@@ -0,0 +1,350 @@
1
+ // hive-mux.js -- the client half of ONE socket per participant (wire format: hive-mux-proto.js).
2
+ //
3
+ // sync.js asks this module for a provider per file doc and gets back an object with the surface
4
+ // it already used from y-websocket's WebsocketProvider: `synced`, the 'sync' / 'status' /
5
+ // 'connection-error' / 'connection-close' events, and destroy(). Behind it is EITHER a channel
6
+ // on the participant's one mux socket, or -- when the relay does not advertise mux in /health,
7
+ // or HIVE_MUX=0 -- a plain per-file WebsocketProvider exactly as before. An old relay therefore
8
+ // keeps working with a new client, and the choice is made once per session, before any file
9
+ // socket exists, so a room never ends up half on one transport by accident.
10
+ //
11
+ // Inside a channel the y-protocols exchange is the one y-websocket performs: on admission send
12
+ // sync step 1, answer the relay's step 1, count the session synced when step 2 arrives, forward
13
+ // local updates, never echo an update this provider applied. A refused OPEN is reported as a
14
+ // 'connection-error' carrying the same HTTP status a refused per-file upgrade carried, so every
15
+ // piece of refusal reporting in sync.js (Q430/Q431 counts, notSyncing, explain()) works unchanged.
16
+ import * as syncProtocol from 'y-protocols/sync'
17
+ import * as encoding from 'lib0/encoding'
18
+ import * as decoding from 'lib0/decoding'
19
+ import { ObservableV2 } from 'lib0/observable'
20
+ import { WebsocketProvider } from 'y-websocket'
21
+ import { F, encodeFrame, decodeFrame, muxPath, MUX_VERSION } from './hive-mux-proto.js'
22
+
23
+ const messageSync = 0
24
+
25
+ export const httpBase = (relay) => String(relay).replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '')
26
+
27
+ /** The provider sync.js holds per file. Delegates to a mux channel or a legacy provider. */
28
+ class FileProvider extends ObservableV2 {
29
+ constructor(docName, doc) {
30
+ super()
31
+ this.docName = docName
32
+ this.doc = doc
33
+ this.destroyed = false
34
+ this._impl = null
35
+ }
36
+ get synced() { return !!(this._impl && this._impl.synced) }
37
+ get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
38
+ get transport() { return this._impl ? this._impl.transport : 'pending' }
39
+ _bindLegacy(relay, wsOpts) {
40
+ const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
41
+ for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
42
+ this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
43
+ }
44
+ destroy() {
45
+ if (this.destroyed) return
46
+ this.destroyed = true
47
+ try { if (this._impl) this._impl.destroy() } catch { }
48
+ super.destroy()
49
+ }
50
+ }
51
+
52
+ class Channel {
53
+ constructor(mux, id, docName, doc, facade) {
54
+ this.mux = mux
55
+ this.id = id
56
+ this.docName = docName
57
+ this.doc = doc
58
+ this.facade = facade
59
+ this.transport = 'mux'
60
+ this.opened = false
61
+ this.synced = false
62
+ this.retries = 0
63
+ this.retryTimer = null
64
+ this.destroyed = false
65
+ this._onUpdate = (update, origin) => {
66
+ if (origin === this.facade || !this.opened) return // gaps are closed by the next step1/step2
67
+ const e = encoding.createEncoder()
68
+ encoding.writeVarUint(e, messageSync)
69
+ syncProtocol.writeUpdate(e, update)
70
+ this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
71
+ }
72
+ doc.on('update', this._onUpdate)
73
+ }
74
+ get wsconnected() { return this.opened && this.mux.connected }
75
+ sendOpen() {
76
+ if (this.destroyed || !this.mux.connected) return
77
+ clearTimeout(this.retryTimer); this.retryTimer = null
78
+ this.mux.send(encodeFrame(F.OPEN, this.id, this.docName))
79
+ }
80
+ retryLater() {
81
+ if (this.destroyed || this.retryTimer) return
82
+ // Refused or closed by the relay: back off, like the per-file provider did, but further --
83
+ // an OPEN is cheap to send and a relay at capacity should not be asked 40 times a second.
84
+ const ms = Math.min(15000, 500 * 2 ** Math.min(this.retries++, 5))
85
+ this.retryTimer = setTimeout(() => { this.retryTimer = null; this.sendOpen() }, ms)
86
+ if (this.retryTimer.unref) this.retryTimer.unref()
87
+ }
88
+ onFrame(f) {
89
+ if (this.destroyed) return
90
+ if (f.kind === F.OPENED) {
91
+ this.opened = true
92
+ this.retries = 0
93
+ const e = encoding.createEncoder()
94
+ encoding.writeVarUint(e, messageSync)
95
+ syncProtocol.writeSyncStep1(e, this.doc)
96
+ this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
97
+ this.facade.emit('status', [{ status: 'connected' }])
98
+ } else if (f.kind === F.MSG) {
99
+ try { this.onMessage(f.payload) } catch (e) {
100
+ // A message this doc could not apply. Skipping it would leave a gap that every later
101
+ // update builds on, silently; reopening makes the relay run the sync exchange again,
102
+ // and that carries whatever was missed.
103
+ this.mux.log(`[hive] could not apply a relay message for ${JSON.stringify(this.docName)} (${e && e.message}); resyncing it`)
104
+ this.reopen()
105
+ }
106
+ } else if (f.kind === F.REFUSED) {
107
+ this.opened = false
108
+ this.facade.emit('connection-error', [{ code: f.code, status: f.code, reason: f.reason, message: `Unexpected server response: ${f.code} ${f.reason}` }])
109
+ this.retryLater()
110
+ } else if (f.kind === F.CLOSED) {
111
+ this.lost({ code: 4000, reason: f.reason })
112
+ this.retryLater()
113
+ }
114
+ }
115
+ onMessage(payload) {
116
+ let d
117
+ try { d = decoding.createDecoder(payload) } catch { return }
118
+ const type = decoding.readVarUint(d)
119
+ if (type !== messageSync) return // awareness/auth/query: file docs carry no presence
120
+ const e = encoding.createEncoder()
121
+ encoding.writeVarUint(e, messageSync)
122
+ const t = syncProtocol.readSyncMessage(d, e, this.doc, this.facade)
123
+ if (t === syncProtocol.messageYjsSyncStep2 && !this.synced) {
124
+ this.synced = true
125
+ this.facade.emit('sync', [true])
126
+ }
127
+ if (encoding.length(e) > 1) this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
128
+ }
129
+ reopen() {
130
+ if (this.destroyed) return
131
+ if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
132
+ this.lost({ code: 4001, reason: 'resync' })
133
+ this.retryLater()
134
+ }
135
+ /** The channel is gone (socket dropped, or the relay closed it); a reconnect will reopen it. */
136
+ lost(ev = null) {
137
+ const wasOpen = this.opened
138
+ this.opened = false
139
+ if (this.synced) { this.synced = false; this.facade.emit('sync', [false]) }
140
+ if (wasOpen) {
141
+ this.facade.emit('connection-close', [ev])
142
+ this.facade.emit('status', [{ status: 'disconnected' }])
143
+ }
144
+ }
145
+ destroy() {
146
+ if (this.destroyed) return
147
+ this.destroyed = true
148
+ clearTimeout(this.retryTimer)
149
+ try { this.doc.off('update', this._onUpdate) } catch { }
150
+ if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
151
+ this.mux.channels.delete(this.id)
152
+ }
153
+ }
154
+
155
+ /** The participant's one physical socket, carrying a channel per open file doc. */
156
+ class MuxConnection {
157
+ constructor({ relay, room, params, WebSocketPolyfill, log, onRefused }) {
158
+ const q = new URLSearchParams({ ...(params || {}), mux: String(MUX_VERSION) })
159
+ this.url = `${String(relay).replace(/\/+$/, '')}/${muxPath(room)}?${q}`
160
+ this.WS = WebSocketPolyfill
161
+ this.log = log || (() => { })
162
+ this.onRefused = onRefused || (() => { })
163
+ this.channels = new Map()
164
+ this.nextId = 1
165
+ this.ws = null
166
+ this.connected = false
167
+ this.connecting = false
168
+ this.destroyed = false
169
+ this.failures = 0
170
+ this.lastHeard = 0
171
+ this.reconnectTimer = null
172
+ // The relay sends a BEAT every 20s; 45s of silence means the socket is dead even if the OS
173
+ // has not said so, which is exactly the half-open state a laptop lid produces.
174
+ this.liveness = setInterval(() => {
175
+ // terminate, not close: a close handshake cannot complete on a dead link, and ws waits 30s more for it
176
+ if (this.connected && Date.now() - this.lastHeard > 45000) { try { this.ws.terminate ? this.ws.terminate() : this.ws.close() } catch { } }
177
+ }, 5000)
178
+ if (this.liveness.unref) this.liveness.unref()
179
+ }
180
+ channel(docName, doc, facade) {
181
+ const ch = new Channel(this, this.nextId++, docName, doc, facade)
182
+ this.channels.set(ch.id, ch)
183
+ if (this.connected) ch.sendOpen()
184
+ else this.connect()
185
+ return ch
186
+ }
187
+ send(bytes) {
188
+ if (!this.connected || !this.ws) return
189
+ try { this.ws.send(bytes) } catch { /* the close handler takes it from here */ }
190
+ }
191
+ connect() {
192
+ if (this.destroyed || this.connected || this.connecting) return
193
+ this.connecting = true
194
+ let ws
195
+ try { ws = new this.WS(this.url) } catch (e) { this.connecting = false; this.scheduleReconnect(); return }
196
+ ws.binaryType = 'arraybuffer'
197
+ this.ws = ws
198
+ ws.onopen = () => {
199
+ if (this.ws !== ws) return
200
+ this.connecting = false
201
+ this.connected = true
202
+ this.failures = 0
203
+ this.lastHeard = Date.now()
204
+ for (const ch of this.channels.values()) ch.sendOpen()
205
+ }
206
+ ws.onmessage = (ev) => {
207
+ if (this.ws !== ws) return
208
+ this.lastHeard = Date.now()
209
+ let f
210
+ try { f = decodeFrame(new Uint8Array(ev.data)) } catch { return }
211
+ if (f.kind === F.BEAT) return
212
+ const ch = this.channels.get(f.id)
213
+ if (ch) ch.onFrame(f)
214
+ }
215
+ ws.onerror = (ev) => {
216
+ if (this.ws !== ws) return
217
+ // A refused UPGRADE of the mux socket itself (401/402/403/429/503) refuses every file on it.
218
+ // Hand each unsynced channel the same event a refused per-file socket produced.
219
+ const m = /Unexpected server response:\s*(\d{3})/.exec((ev && (ev.message || (ev.error && ev.error.message))) || '')
220
+ const code = m ? Number(m[1]) : null
221
+ for (const ch of this.channels.values()) {
222
+ if (!ch.synced) ch.facade.emit('connection-error', [{ code, status: code, message: (ev && ev.message) || 'mux socket error' }])
223
+ }
224
+ if (code) this.onRefused(code)
225
+ }
226
+ ws.onclose = (ev) => {
227
+ if (this.ws !== ws) return
228
+ this.ws = null
229
+ this.connected = false
230
+ this.connecting = false
231
+ for (const ch of this.channels.values()) ch.lost(ev)
232
+ this.scheduleReconnect()
233
+ }
234
+ }
235
+ scheduleReconnect() {
236
+ if (this.destroyed || this.reconnectTimer || !this.channels.size) return
237
+ const ms = Math.min(5000, 100 * 2 ** Math.min(this.failures++, 6))
238
+ this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect() }, ms)
239
+ if (this.reconnectTimer.unref) this.reconnectTimer.unref()
240
+ }
241
+ destroy() {
242
+ this.destroyed = true
243
+ clearInterval(this.liveness)
244
+ clearTimeout(this.reconnectTimer)
245
+ const ws = this.ws
246
+ this.ws = null
247
+ // Disconnected FIRST: closing the socket ends every channel on the relay at once, so no
248
+ // channel sends a CLOSE frame of its own down a socket that is about to go.
249
+ this.connected = false
250
+ for (const ch of [...this.channels.values()]) { try { ch.destroy() } catch { } }
251
+ try { if (ws) ws.close() } catch { }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * One per startSync. provide(docName, doc) returns the provider for a file doc; the transport is
257
+ * decided once -- HIVE_MUX=1 forces mux, HIVE_MUX=0 forces a socket per file, anything else asks
258
+ * the relay's /health -- and file providers created before the answer are bound when it comes.
259
+ */
260
+ export function createFileTransport({ relay, room, wsOpts = {}, log = () => { }, env = process.env, fetchImpl = globalThis.fetch, onFallback = () => { }, retryMs = 1000, detectForMs = 90000 }) {
261
+ const forced = env.HIVE_MUX === '0' || env.HIVE_MUX === '1'
262
+ let mode = env.HIVE_MUX === '0' ? 'socket' : env.HIVE_MUX === '1' ? 'mux' : null
263
+ let mux = null
264
+ let stopped = false
265
+ const pending = new Set()
266
+ const health = () => Promise.resolve()
267
+ .then(() => fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) }))
268
+ .then((r) => (r && r.ok ? r.json() : null))
269
+ const offersMux = (j) => !!j && Number(j.mux) >= MUX_VERSION
270
+
271
+ // THE KILL SWITCH HAS TO REACH CLIENTS THAT ARE ALREADY CONNECTED. A relay that stops serving
272
+ // mux -- an operator setting HIVE_RELAY_MUX=off, or a rollback to a build that predates it --
273
+ // refuses the mux socket from then on, and without this every file would stay refused until
274
+ // the agent restarted. A 404 says so outright. Any other refusal is checked against /health, at
275
+ // most once a minute, because a relay that predates mux reads the mux path as a room name and
276
+ // refuses the token -- a 401 that looks exactly like a real one.
277
+ let lastCheck = 0
278
+ const onRefused = (code) => {
279
+ if (forced || stopped || mode !== 'mux') return
280
+ if (code === 404) return fallBack('the relay no longer serves one-socket mode (404)')
281
+ const now = Date.now()
282
+ if (now - lastCheck < 60000) return
283
+ lastCheck = now
284
+ health().then((j) => { if (j && !offersMux(j)) fallBack('the relay stopped offering one-socket mode') }, () => { })
285
+ }
286
+ const fallBack = (why) => {
287
+ if (stopped || mode !== 'mux') return
288
+ mode = 'socket'
289
+ log(`[hive] ${why}: falling back to a socket per file`)
290
+ // sync.js lets go of every file beyond the per-file bound FIRST. Without that, a client holding
291
+ // 900 files on one socket would open 900 sockets at once -- the storm the bound exists to stop.
292
+ try { onFallback() } catch { }
293
+ const moving = mux ? [...mux.channels.values()].map((ch) => ch.facade) : []
294
+ try { if (mux) mux.destroy() } catch { }
295
+ mux = null
296
+ for (const fp of moving) { if (!fp.destroyed) fp._bindLegacy(relay, wsOpts) }
297
+ }
298
+ const bind = (fp) => {
299
+ if (fp.destroyed) return
300
+ if (mode === 'mux') {
301
+ if (!mux) mux = new MuxConnection({ relay, room, params: wsOpts.params, WebSocketPolyfill: wsOpts.WebSocketPolyfill, log, onRefused })
302
+ fp._impl = mux.channel(fp.docName, fp.doc, fp)
303
+ } else {
304
+ fp._bindLegacy(relay, wsOpts)
305
+ }
306
+ }
307
+ let resolveReady
308
+ const ready = new Promise((r) => { resolveReady = r })
309
+ const decide = (m) => {
310
+ if (!mode) mode = m
311
+ for (const fp of pending) bind(fp)
312
+ pending.clear()
313
+ resolveReady(mode)
314
+ }
315
+ // ONLY AN ANSWER DECIDES. A free Render instance takes 30-60s to wake and says nothing at all
316
+ // meanwhile; reading that silence as "no mux" pinned the whole session to a socket per file.
317
+ // So silence (a timeout, a refused connection, a 5xx from a proxy fronting a booting service) is
318
+ // asked again for up to ~90s. Any real answer decides at once -- including an old relay's
319
+ // plain-text /health, which is a relay that has never heard of mux. Out of time: a socket per
320
+ // file, the transport every relay has always spoken.
321
+ const detect = async () => {
322
+ const deadline = Date.now() + detectForMs
323
+ for (let wait = retryMs; ; wait = Math.min(wait * 2, 15000)) {
324
+ try {
325
+ const r = await fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) })
326
+ if (r && r.status < 500) {
327
+ let j = null
328
+ try { j = JSON.parse(await r.text()) } catch { /* not JSON: an old relay, or not a relay */ }
329
+ return r.ok && offersMux(j) ? 'mux' : 'socket'
330
+ }
331
+ } catch { /* no answer yet */ }
332
+ if (stopped || Date.now() + wait > deadline) return 'socket'
333
+ await new Promise((res) => { const t = setTimeout(res, wait); if (t.unref) t.unref() })
334
+ }
335
+ }
336
+ if (mode) resolveReady(mode)
337
+ else detect().then(decide, () => decide('socket'))
338
+ return {
339
+ ready,
340
+ get mode() { return mode || 'detecting' },
341
+ provide(docName, doc) {
342
+ const fp = new FileProvider(docName, doc)
343
+ if (mode) bind(fp)
344
+ else pending.add(fp)
345
+ return fp
346
+ },
347
+ stats() { return { mode: mode || 'detecting', channels: mux ? mux.channels.size : 0, muxConnected: !!(mux && mux.connected) } },
348
+ stop() { stopped = true; try { if (mux) mux.destroy() } catch { } },
349
+ }
350
+ }
package/hive-privacy.js CHANGED
@@ -42,6 +42,31 @@ export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
42
42
  // outside this list is called.
43
43
  export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
44
44
 
45
+ /**
46
+ * The relay a client should actually dial for a URL it was handed.
47
+ *
48
+ * A RETIRED PUBLIC RELAY IS FOLLOWED TO ITS REPLACEMENT, and nothing else is ever rewritten.
49
+ * Measured 23 Sep 2026 on 0.12.1 -- a build that already shipped the new default: a project
50
+ * folder whose .hive.json was written before the move still named the retired host, the client
51
+ * obeyed it, and a returning user got "NOT JOINED ... 503" exactly as if nothing had been fixed.
52
+ * The same held for HIVE_RELAY set to the old host, which docs/MCP.md told people to do.
53
+ *
54
+ * That address was never the user's choice -- it was this project's default, copied into their
55
+ * config by the client and by our own docs -- so following it to the current default honours
56
+ * what they actually chose (the public relay), and moves nobody from anyone to anyone else.
57
+ * A relay the user runs is never in RETIRED_PUBLIC_RELAYS and is returned untouched.
58
+ *
59
+ * Returns { relay, migratedFrom } -- migratedFrom is the retired URL when a move happened,
60
+ * so the caller can say so on screen instead of changing someone's destination silently.
61
+ */
62
+ export function currentRelayFor(url) {
63
+ const origin = relayOrigin(url)
64
+ if (origin && RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) {
65
+ return { relay: PUBLIC_DEFAULT_RELAY, migratedFrom: String(url).trim() }
66
+ }
67
+ return { relay: url, migratedFrom: null }
68
+ }
69
+
45
70
  const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
46
71
 
47
72
  /**