hivecode-mcp 0.14.1 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/hive-mcp.js
CHANGED
|
@@ -609,9 +609,10 @@ const connectionOf = (hive) => (typeof hive.connection === 'function' ? hive.con
|
|
|
609
609
|
// copy changed and whether this folder's copy arrived. Said, not refused: claiming and then deciding
|
|
610
610
|
// not to edit is legitimate.
|
|
611
611
|
const claimSnaps = new Map() // `${room}|${region}` -> sha256 of the room's text at claim time
|
|
612
|
+
const toLF = (s) => (typeof s === 'string' && s.indexOf('\r\n') !== -1 ? s.replace(/\r\n/g, '\n') : s)
|
|
612
613
|
const textHash = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
|
|
613
614
|
const roomTextOf = (s, region) => { try { const t = s.hive._fileText(region); return typeof t === 'string' ? t : null } catch { return null } }
|
|
614
|
-
const diskTextOf = (s, region) => { try { return fs.readFileSync(path.join(s.dir, ...region.split('/')), 'utf8') } catch { return null } }
|
|
615
|
+
const diskTextOf = (s, region) => { try { return toLF(fs.readFileSync(path.join(s.dir, ...region.split('/')), 'utf8')) } catch { return null } }
|
|
615
616
|
function noteClaim(s, region) {
|
|
616
617
|
const t = roomTextOf(s, region)
|
|
617
618
|
claimSnaps.set(`${s.room}|${region}`, t == null ? null : textHash(t))
|
|
@@ -880,8 +881,8 @@ const TOOLS = [
|
|
|
880
881
|
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded against you and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
|
|
881
882
|
{ name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
|
|
882
883
|
inputSchema: { type: 'object', properties: {} } },
|
|
883
|
-
{ name: 'hive_edit', description: 'Write a file through the medium (preferred over editing on disk): your intent and the exact change are signed into the provenance ledger as it lands. Claim the file first.',
|
|
884
|
-
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'why you are making the change (signed into provenance)' } }, required: ['file'
|
|
884
|
+
{ name: 'hive_edit', description: 'Write a file through the medium (preferred over editing on disk): your intent and the exact change are signed into the provenance ledger as it lands. Claim the file first. Changing existing lines: pass old (whole lines, exactly as you read them) and new; if those lines changed since you read them it is refused and nothing is written. A whole new file: pass content.',
|
|
885
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative path in the room' }, old: { type: 'string', description: 'whole lines you are replacing, exactly as you read them; must appear once' }, new: { type: 'string', description: 'what replaces old' }, content: { type: 'string', description: 'the complete new file content (instead of old/new)' }, intent: { type: 'string', description: 'why you are making the change (signed into provenance)' } }, required: ['file'] } },
|
|
885
886
|
{ name: 'hive_fork', description: 'Read a silent fork on a file: the common base plus every divergent version with its author and signed intent. Reconcile them, then submit with hive_resolve.',
|
|
886
887
|
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path, as announced in chat' } }, required: ['file'] } },
|
|
887
888
|
{ name: 'hive_resolve', description: 'Resolve a fork with the full reconciled file, serving every side\'s intent. It must parse with no dangling references, or it is refused and nothing changes.',
|
|
@@ -976,11 +977,14 @@ function enforceDcoLock(opts = {}) {
|
|
|
976
977
|
// violated within the hour by everyone including its author. A COUNTED lease makes the second
|
|
977
978
|
// write impossible rather than discouraged: the server spends it, and the window shuts itself.
|
|
978
979
|
const lease = /^PUBLISH(?::(\d+))?$/.exec(String(status || ''))
|
|
979
|
-
|
|
980
|
+
// `peek` checks the lease without spending it: hive_edit old/new peeks, writes, and spends only when
|
|
981
|
+
// something landed, so a refused or no-op edit keeps the window (antigravity-3, 1 Nov).
|
|
982
|
+
if (lease && (opts.consumes || opts.peek)) {
|
|
980
983
|
const left = lease[1] === undefined ? 1 : Number(lease[1])
|
|
981
984
|
if (left <= 0) {
|
|
982
985
|
throw new Error(`[Lease Spent] Your PUBLISH window is used up. Ask for a new one — and if this is a revision of what you just wrote, publish to a NEW path rather than rewriting it.`)
|
|
983
986
|
}
|
|
987
|
+
if (!opts.consumes) return
|
|
984
988
|
const next = left - 1
|
|
985
989
|
try {
|
|
986
990
|
s.hive.setState(`${name}_status`, next > 0 ? `PUBLISH:${next}` : 'LOCKED')
|
|
@@ -1739,9 +1743,15 @@ Then call hive_release.`)
|
|
|
1739
1743
|
case 'hive_claims': { const b = requireSession().hive.claimsBoard(); return text(b.length ? b.map((c) => `${c.region} — ${c.by}${c.intent ? ` (${c.intent})` : ''}`).join('\n') : '(nothing claimed right now — all open)') }
|
|
1740
1744
|
case 'hive_edit': {
|
|
1741
1745
|
const editPath = String(args.file || '')
|
|
1746
|
+
// Two modes, never mixed: content is the whole file, old/new is the lines the agent edited.
|
|
1747
|
+
// Only `file` is required by the schema now, so these checks are what refuse a call missing both.
|
|
1748
|
+
const byLines = args.old !== undefined || args.new !== undefined
|
|
1749
|
+
if (byLines && args.content !== undefined) return err('pass either content (the whole file) or old and new (the lines you changed), not both. Nothing was written.')
|
|
1750
|
+
if (byLines && (typeof args.old !== 'string' || typeof args.new !== 'string')) return err('old/new needs both old and new. Nothing was written.')
|
|
1751
|
+
if (!byLines && typeof args.content !== 'string') return err('pass content (the whole file), or old and new (the lines you changed). Nothing was written.')
|
|
1742
1752
|
// Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
|
|
1743
1753
|
// indistinguishable from no lease at all.
|
|
1744
|
-
enforceDcoLock({ consumes: true, path: editPath })
|
|
1754
|
+
enforceDcoLock(byLines ? { peek: true, path: editPath } : { consumes: true, path: editPath })
|
|
1745
1755
|
// HIV-27, same reasoning: this tool's entire promise is that the change reaches every
|
|
1746
1756
|
// peer. On a path this client never opens a connection for, it would not -- and it
|
|
1747
1757
|
// would still report success.
|
|
@@ -1751,15 +1761,32 @@ Then call hive_release.`)
|
|
|
1751
1761
|
|
|
1752
1762
|
Nothing was written. This tool exists to guarantee the change syncs to every peer; on this path it cannot.`)
|
|
1753
1763
|
}
|
|
1754
|
-
|
|
1764
|
+
let r
|
|
1765
|
+
if (byLines) {
|
|
1766
|
+
// A file the room has but this folder has not loaded yet comes back as `loading`: wait for it
|
|
1767
|
+
// (up to 8 s) rather than hand the agent a retry it may answer with a blind content write.
|
|
1768
|
+
for (let t = 0; ; t++) {
|
|
1769
|
+
if (t) enforceDcoLock({ peek: true, path: editPath })
|
|
1770
|
+
r = requireSession().hive.editLines(editPath, args.old, args.new, String(args.intent || ''))
|
|
1771
|
+
if (!r.loading || t >= 32) break
|
|
1772
|
+
await new Promise((res) => setTimeout(res, 250))
|
|
1773
|
+
}
|
|
1774
|
+
if (r.ok && !r.noop) enforceDcoLock({ consumes: true, path: editPath }) // peeked just above, nothing awaited since
|
|
1775
|
+
} else r = requireSession().hive.edit(editPath, args.content, String(args.intent || ''))
|
|
1755
1776
|
if (r.error) return err(r.error)
|
|
1777
|
+
if (r.noop) return text(`nothing to change: old and new are the same in ${r.file}. Nothing was written.`)
|
|
1756
1778
|
if (r.withheld) {
|
|
1757
1779
|
firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
|
|
1758
1780
|
return text(`⛔ wrote ${r.file} in YOUR folder only. It was NOT shared, because it contains ${r.withheld}, and Hivecode does not send keys to the room or the relay. Nobody else has this change. Read the key from the environment instead (put it in .env, which is never shared); the file is shared on its next save without the key.`)
|
|
1759
1781
|
}
|
|
1760
|
-
return text(r.
|
|
1761
|
-
|
|
1762
|
-
|
|
1782
|
+
if (r.conflicted) return text(`wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`)
|
|
1783
|
+
// SAY WHETHER IT REACHED THE RELAY, never just "syncing" (0.14.3 P0-1). Over HTTPS long-poll an
|
|
1784
|
+
// edit waits in this process until the relay accepts it; an agent that left on "syncing" lost it.
|
|
1785
|
+
const hiveS = requireSession().hive
|
|
1786
|
+
const sent = typeof hiveS.whenSent === 'function' ? await hiveS.whenSent(r.file, 3000) : true
|
|
1787
|
+
return text(sent
|
|
1788
|
+
? `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the relay has the change; every peer gets it from there.`
|
|
1789
|
+
: `⏳ wrote ${r.file} in your folder, but the relay has NOT accepted the change yet (slow or reconnecting link). It is still being sent: stay in the room (do not exit or leave) and check hive_status before you report it done.`)
|
|
1763
1790
|
}
|
|
1764
1791
|
case 'hive_fork': {
|
|
1765
1792
|
const file = String(args.file || ''); if (!file) return err('need a file path')
|
|
@@ -1836,6 +1863,11 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1836
1863
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
1837
1864
|
connected: conn ? conn.connected : null,
|
|
1838
1865
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1866
|
+
// How full this client's bounded working set is: files open / the bound (HIVE_MAX_OPEN_FILES, or
|
|
1867
|
+
// 4096 on one socket, 128 with a socket per file). At the bound, files that were in the room
|
|
1868
|
+
// at join arrive when touched; new ones still open (sync.js newRemote), and `waiting` counts
|
|
1869
|
+
// the new ones queued for room.
|
|
1870
|
+
...(conn && conn.openCap ? { workingSet: `${conn.files}/${conn.openCap} open (${conn.transport === 'mux' ? 'one socket' : conn.transport === 'socket' ? 'a socket per file' : conn.transport})${conn.newWaiting ? `, ${conn.newWaiting} new waiting` : ''}` } : {}),
|
|
1839
1871
|
// 'ws', or 'poll' where this network refuses WebSockets (HTTPS long-poll: same rooms, same rules).
|
|
1840
1872
|
transport: conn ? transportFor(s.relay) : null,
|
|
1841
1873
|
...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
|
|
@@ -1849,7 +1881,17 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1849
1881
|
// row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
|
|
1850
1882
|
// and diluting it with agents that signed off correctly is how a signal stops being read.
|
|
1851
1883
|
case 'hive_pr': return text(await runHivePr(args))
|
|
1852
|
-
case 'hive_leave': {
|
|
1884
|
+
case 'hive_leave': {
|
|
1885
|
+
try { ACT.forget(activityMap(), requireSession().name) } catch { }
|
|
1886
|
+
const h = requireSession().hive
|
|
1887
|
+
// Leave waits until the relay has every edit and message (0.14.3 P0-1), and names what it did not get.
|
|
1888
|
+
const r = typeof h.leave === 'function' ? await h.leave({ timeoutMs: 10000 }) : (h.stop(), null)
|
|
1889
|
+
session = null; forgetSession()
|
|
1890
|
+
const missed = r ? [...r.unsent, ...(r.roomUnsent ? ['your last room messages'] : [])] : []
|
|
1891
|
+
return missed.length
|
|
1892
|
+
? text(`left the room, but the relay had NOT accepted these when it stopped waiting (10 s): ${missed.join(', ')}. Nobody else has those changes. Rejoin and check them before you report the work done.`)
|
|
1893
|
+
: text('left the room')
|
|
1894
|
+
}
|
|
1853
1895
|
default: return err(`unknown tool: ${name}`)
|
|
1854
1896
|
}
|
|
1855
1897
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
@@ -1979,9 +2021,37 @@ function missingRequired(req) {
|
|
|
1979
2021
|
return lines.join('\n')
|
|
1980
2022
|
}
|
|
1981
2023
|
|
|
2024
|
+
// [!] MEANS "BEFORE YOU CONTINUE", SO IT IS CHECKED BEFORE THE CALL, NOT AFTER IT (P1-8, 9 Nov 2026).
|
|
2025
|
+
// Mail reached an agent only as a notice appended to a tool's RESULT, after the tool had run. In the
|
|
2026
|
+
// 9 Nov mock run Claude sent "[!] @antigravity-VM Stand down on test.js: ... Don't edit it."; the
|
|
2027
|
+
// agent's next call was hive_edit test.js, the file was written, and "[!] URGENT MAIL" came back in
|
|
2028
|
+
// the reply to that very write. The room read it as "delivered to one of two agents". So a call that
|
|
2029
|
+
// writes, takes work or lands it is refused while urgent mail addressed to this agent is unread, and
|
|
2030
|
+
// the refusal shows the message. Reading it (hive_check or hive_wait) is the decision; then retry.
|
|
2031
|
+
// Releasing, talking and reading are never held: a stand-down usually asks for exactly those.
|
|
2032
|
+
const URGENT_GATED = new Set(['hive_edit', 'hive_claim', 'hive_resolve', 'hive_fork', 'hive_pr', 'hive_set_state'])
|
|
2033
|
+
function urgentGate(name) {
|
|
2034
|
+
try {
|
|
2035
|
+
if (!URGENT_GATED.has(name) || !session || !session.mailbox) return ''
|
|
2036
|
+
catchUp(session)
|
|
2037
|
+
const m = session.mailbox.peek(chatArray())
|
|
2038
|
+
const urgent = (m.mentions || []).filter((x) => x && priorityOf(x.text) === 'stop')
|
|
2039
|
+
if (!urgent.length) return ''
|
|
2040
|
+
const shown = urgent.slice(-3).map((x) => ` ${formatMessage(x)}`.slice(0, 600)).join('\n')
|
|
2041
|
+
return [
|
|
2042
|
+
`REFUSED -- ${urgent.length} URGENT message(s) ([!]) addressed to you are unread, and [!] means: handle it before you continue.`,
|
|
2043
|
+
`Nothing was done. This ${name} call did NOT happen.`,
|
|
2044
|
+
shown,
|
|
2045
|
+
`Read it with hive_check (that marks it read), do what it asks, then call ${name} again if it still applies.`,
|
|
2046
|
+
].join('\n')
|
|
2047
|
+
} catch { return '' }
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1982
2050
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1983
2051
|
const bad = missingRequired(req)
|
|
1984
2052
|
if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
|
|
2053
|
+
const held = urgentGate(req.params && req.params.name)
|
|
2054
|
+
if (held) return { content: [{ type: 'text', text: held }], isError: true }
|
|
1985
2055
|
const res = await dispatch(req)
|
|
1986
2056
|
// Never let the banner break a tool: append only to a well-formed text result, and skip the two
|
|
1987
2057
|
// tools that already deliver mail themselves (appending would double-report the same messages).
|
|
@@ -2051,8 +2121,18 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
2051
2121
|
// room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
|
|
2052
2122
|
// and a presence entry for a member that was gone, until something killed it.
|
|
2053
2123
|
server.onclose = () => {
|
|
2054
|
-
|
|
2055
|
-
|
|
2124
|
+
// Leave, not stop: the last edits and messages go out before the process does (0.14.3 P0-1). Over
|
|
2125
|
+
// HTTPS long-poll they are still in this process; a 500 ms exit dropped them. Capped, so a dead
|
|
2126
|
+
// link cannot hold a departed agent's seat for long; whatever did not go is named on stderr.
|
|
2127
|
+
const done = () => process.exit(0)
|
|
2128
|
+
setTimeout(done, 12000).unref()
|
|
2129
|
+
const s = session
|
|
2130
|
+
if (!s) return setTimeout(done, 500)
|
|
2131
|
+
Promise.resolve()
|
|
2132
|
+
.then(() => (typeof s.hive.leave === 'function' ? s.hive.leave({ timeoutMs: 10000 }) : (s.hive.stop(), null)))
|
|
2133
|
+
.then((r) => { if (r && (r.unsent.length || r.roomUnsent)) console.error(`[hive-mcp] left before the relay accepted: ${[...r.unsent, ...(r.roomUnsent ? ['(room messages)'] : [])].join(', ')}`) })
|
|
2134
|
+
.catch(() => { /* leaving is best-effort; exiting is not */ })
|
|
2135
|
+
.finally(() => setTimeout(done, 200))
|
|
2056
2136
|
}
|
|
2057
2137
|
await server.connect(transport)
|
|
2058
2138
|
startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
|
package/hive-mux-proto.js
CHANGED
|
@@ -1,58 +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) }
|
|
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) }
|