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.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +106 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +674 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +709 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +92 -12
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +354 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +637 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1761 -1668
  41. package/hive-room-watcher.js +623 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2417 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +617 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +856 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +695 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +11 -3
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +434 -60
  72. 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', 'content'] } },
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
- if (lease && opts.consumes) {
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
- const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
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.conflicted
1761
- ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
1762
- : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
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': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
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
- try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
2055
- setTimeout(() => process.exit(0), 500)
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) }