hivecode-mcp 0.11.0 → 0.11.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/limits.js ADDED
@@ -0,0 +1,346 @@
1
+ // USAGE LIMITS — what stops one misbehaving client from degrading a room for everyone
2
+ // who paid for it.
3
+ //
4
+ // Before this, the only bound anywhere in the relay was `MAX_NAME = 48`. A single agent in a
5
+ // loop could push unbounded messages, grow a document without limit, or open connections
6
+ // until the process died — and the blast radius is not that client, it is every other room
7
+ // on the same relay. That is the difference between "works on my machine" and something you
8
+ // can charge for.
9
+ //
10
+ // Four limits, each chosen because an agent can actually hit it by accident:
11
+ //
12
+ // MESSAGES an agent stuck in a write loop. Token bucket, so normal bursty sync (a big
13
+ // paste, a first-join catch-up) passes and a sustained flood does not.
14
+ // BYTES the same loop with big payloads. Rate-limiting messages alone lets 10 msg/s
15
+ // of 5 MB through, which is the same outage with fewer packets.
16
+ // DOC SIZE a file (or a room) that grows without bound. A CRDT never shrinks: every
17
+ // insert is retained history, so "it will be garbage collected" is not true
18
+ // here. Past the cap we refuse WRITES and keep serving reads, because dropping
19
+ // the client would take their unsaved work with it.
20
+ // CONNS connection floods, per room and per relay. Separate from the committer/seat
21
+ // limit: seats are about entitlement, this is about survival.
22
+ //
23
+ // EVERY limit is a REFUSAL WITH A REASON, never a silent drop. An agent that is told
24
+ // `Q4xx over the message rate` can back off; one whose writes vanish will retry forever and
25
+ // look like a broken product. Nothing here throttles reads.
26
+ //
27
+ // Defaults are deliberately generous — roughly 100x what the run2 four-agent session used at
28
+ // peak — so a real team never meets them and a runaway loop meets them in about a second.
29
+ // All of them are env-tunable so a self-hoster can raise them without a code change.
30
+ //
31
+ // ASCII only. Q-codes on refusals.
32
+
33
+ const num = (name, dflt) => {
34
+ const v = Number(process.env[name])
35
+ return Number.isFinite(v) && v > 0 ? v : dflt
36
+ }
37
+
38
+ // ── TIERS ───────────────────────────────────────────────────────────────────────────────
39
+ // The first version of this file applied ONE set of limits to everybody, which is wrong for
40
+ // a product that charges: a free room got the same 64 MB document ceiling and the same
41
+ // message rate as a room someone is paying $29 a seat for. That is both a giveaway and a
42
+ // support problem -- the paying customer is the one who notices the shared ceiling.
43
+ //
44
+ // So limits scale with the tier the licence actually reports. `plan` comes from the licence
45
+ // record (see license-check.js, which had been dropping it), never from the key string --
46
+ // the whole reason the record is authoritative is that a key told the relay whatever it
47
+ // liked in an earlier version and granted 9,999 seats.
48
+ //
49
+ // The multiplier is per SEAT, deliberately. A 20-seat room legitimately does 10x the traffic
50
+ // of a 2-seat one, and a flat per-room ceiling would punish exactly the customers worth
51
+ // keeping. Free stays generous enough that nobody hits it while evaluating -- the tier is
52
+ // there to bound cost and abuse, not to nag someone into upgrading.
53
+ const TIERS = {
54
+ free: {
55
+ // PLANS.free caps at 2 seats (shared/license.js), so this is sized for two agents doing
56
+ // real work, with headroom. A single evaluator will never see it.
57
+ msgsPerSec: num('HIVE_FREE_MSGS_PER_SEC', 60),
58
+ msgBurst: num('HIVE_FREE_MSG_BURST', 600),
59
+ bytesPerSec: num('HIVE_FREE_BYTES_PER_SEC', 2 * 1024 * 1024),
60
+ byteBurst: num('HIVE_FREE_BYTE_BURST', 8 * 1024 * 1024),
61
+ maxDocBytes: num('HIVE_FREE_MAX_DOC_BYTES', 8 * 1024 * 1024),
62
+ maxConnsPerRoom: num('HIVE_FREE_MAX_CONNS_PER_ROOM', 8),
63
+ },
64
+ pro: {
65
+ // Per seat. A 1-seat Pro room already gets more than the whole free tier.
66
+ msgsPerSecPerSeat: num('HIVE_PRO_MSGS_PER_SEC_PER_SEAT', 100),
67
+ msgBurstPerSeat: num('HIVE_PRO_MSG_BURST_PER_SEAT', 1000),
68
+ bytesPerSecPerSeat: num('HIVE_PRO_BYTES_PER_SEC_PER_SEAT', 4 * 1024 * 1024),
69
+ byteBurstPerSeat: num('HIVE_PRO_BYTE_BURST_PER_SEAT', 16 * 1024 * 1024),
70
+ maxDocBytesPerSeat: num('HIVE_PRO_MAX_DOC_BYTES_PER_SEAT', 32 * 1024 * 1024),
71
+ // Connections, not committers. An agent may hold several file-rooms at once, and a
72
+ // reader costs a socket without costing a seat.
73
+ connsPerSeat: num('HIVE_PRO_CONNS_PER_SEAT', 8),
74
+ // Ceilings so one licence cannot consume the relay no matter how many seats it buys.
75
+ maxDocBytesCeiling: num('HIVE_PRO_MAX_DOC_BYTES_CEILING', 512 * 1024 * 1024),
76
+ maxConnsCeiling: num('HIVE_PRO_MAX_CONNS_CEILING', 256),
77
+ },
78
+ }
79
+
80
+ /**
81
+ * The limits that apply to one connection, from the licence verdict.
82
+ *
83
+ * Called with the verdict verifyLicense() returned at the handshake. An invalid or absent
84
+ * licence is the free tier — NOT a refusal: whether an unlicensed client may connect at all
85
+ * is HIVE_REQUIRE_LICENSE's decision, made before this, and conflating the two would mean a
86
+ * relay running without licensing enforced silently applied nobody's limits.
87
+ */
88
+ export function limitsFor(verdict) {
89
+ const plan = verdict && verdict.valid ? (verdict.plan || 'pro') : 'free'
90
+ if (plan !== 'pro') {
91
+ const f = TIERS.free
92
+ return {
93
+ tier: 'free', seats: 1,
94
+ msgsPerSec: f.msgsPerSec, msgBurst: f.msgBurst,
95
+ bytesPerSec: f.bytesPerSec, byteBurst: f.byteBurst,
96
+ maxFrameBytes: LIMITS.maxFrameBytes,
97
+ maxDocBytes: f.maxDocBytes,
98
+ maxConnsPerRoom: f.maxConnsPerRoom,
99
+ cooloffMs: LIMITS.cooloffMs,
100
+ }
101
+ }
102
+ const seats = Math.max(1, Number(verdict.seats) || 1)
103
+ const p = TIERS.pro
104
+ return {
105
+ tier: 'pro', seats,
106
+ msgsPerSec: p.msgsPerSecPerSeat * seats,
107
+ msgBurst: p.msgBurstPerSeat * seats,
108
+ bytesPerSec: p.bytesPerSecPerSeat * seats,
109
+ byteBurst: p.byteBurstPerSeat * seats,
110
+ maxFrameBytes: LIMITS.maxFrameBytes,
111
+ maxDocBytes: Math.min(p.maxDocBytesPerSeat * seats, p.maxDocBytesCeiling),
112
+ maxConnsPerRoom: Math.min(p.connsPerSeat * seats, p.maxConnsCeiling),
113
+ cooloffMs: LIMITS.cooloffMs,
114
+ }
115
+ }
116
+
117
+ export { TIERS }
118
+
119
+ export const LIMITS = {
120
+ // Token bucket: sustained rate, with a burst allowance for legitimate spikes.
121
+ msgsPerSec: num('HIVE_MAX_MSGS_PER_SEC', 200),
122
+ msgBurst: num('HIVE_MAX_MSG_BURST', 2000),
123
+ bytesPerSec: num('HIVE_MAX_BYTES_PER_SEC', 8 * 1024 * 1024),
124
+ byteBurst: num('HIVE_MAX_BYTE_BURST', 32 * 1024 * 1024),
125
+ // A single frame. y-websocket's first sync step can be large, so this is not tight.
126
+ maxFrameBytes: num('HIVE_MAX_FRAME_BYTES', 16 * 1024 * 1024),
127
+ // Retained CRDT size for one doc. A doc is one file in this design.
128
+ maxDocBytes: num('HIVE_MAX_DOC_BYTES', 64 * 1024 * 1024),
129
+ maxConnsPerRoom: num('HIVE_MAX_CONNS_PER_ROOM', 64),
130
+ maxConnsTotal: num('HIVE_MAX_CONNS_TOTAL', 512),
131
+ // How long a client stays refused after tripping a rate limit. Long enough that a tight
132
+ // loop actually yields, short enough that a human does not notice a one-off burst.
133
+ cooloffMs: num('HIVE_RATE_COOLOFF_MS', 5000),
134
+ }
135
+
136
+ // ── token bucket ────────────────────────────────────────────────────────────────────────
137
+ // Chosen over a fixed window because a fixed window has an edge: 2x the limit can pass
138
+ // across a boundary, which is exactly when a sync storm happens.
139
+ class Bucket {
140
+ constructor(ratePerSec, capacity) {
141
+ this.rate = ratePerSec
142
+ this.cap = capacity
143
+ this.tokens = capacity
144
+ this.last = Date.now()
145
+ }
146
+ take(n, now = Date.now()) {
147
+ const elapsed = (now - this.last) / 1000
148
+ if (elapsed > 0) {
149
+ this.tokens = Math.min(this.cap, this.tokens + elapsed * this.rate)
150
+ this.last = now
151
+ }
152
+ if (this.tokens >= n) { this.tokens -= n; return true }
153
+ return false
154
+ }
155
+ }
156
+
157
+ // ── per-relay and per-room connection counts ────────────────────────────────────────────
158
+ let totalConns = 0
159
+ const roomConns = new Map() // room -> count
160
+
161
+ /**
162
+ * Called at the HTTP upgrade, BEFORE the websocket handshake completes, so a rejected
163
+ * connection costs one socket and no doc state. Returns null to admit, or {code, reason}.
164
+ */
165
+ export function admitConnection(room, tier = null) {
166
+ if (totalConns >= LIMITS.maxConnsTotal) {
167
+ return { code: 503, reason: `Q430 relay at capacity (${LIMITS.maxConnsTotal} connections)` }
168
+ }
169
+ // The per-room ceiling is the TIER's, so a free room cannot hold as many sockets as a
170
+ // 20-seat Pro room. Falls back to the flat value when no tier is supplied, which is what
171
+ // happens on a relay running with licensing disabled.
172
+ const cap = (tier && tier.maxConnsPerRoom) || LIMITS.maxConnsPerRoom
173
+ const n = roomConns.get(room) || 0
174
+ if (n >= cap) {
175
+ const which = tier ? `${tier.tier} tier` : 'this relay'
176
+ return { code: 429, reason: `Q431 room at capacity (${cap} connections on ${which})` }
177
+ }
178
+ return null
179
+ }
180
+
181
+ export function noteConnectionOpened(room) {
182
+ totalConns++
183
+ roomConns.set(room, (roomConns.get(room) || 0) + 1)
184
+ }
185
+
186
+ export function noteConnectionClosed(room) {
187
+ totalConns = Math.max(0, totalConns - 1)
188
+ const n = (roomConns.get(room) || 1) - 1
189
+ if (n <= 0) roomConns.delete(room) // never leak a zero entry per room
190
+ else roomConns.set(room, n)
191
+ }
192
+
193
+ // ── document size ───────────────────────────────────────────────────────────────────────
194
+ // Measured from the encoded update size we have already seen, accumulated per doc. Asking
195
+ // Yjs to encode the whole state on every message would itself be the performance problem.
196
+ const docBytes = new Map() // docName -> approximate retained bytes
197
+
198
+ export function noteDocBytes(docName, n) {
199
+ docBytes.set(docName, (docBytes.get(docName) || 0) + n)
200
+ }
201
+
202
+ export function docOverCap(docName, tier = null) {
203
+ const cap = (tier && tier.maxDocBytes) || LIMITS.maxDocBytes
204
+ return (docBytes.get(docName) || 0) >= cap
205
+ }
206
+
207
+ export function docSize(docName) { return docBytes.get(docName) || 0 }
208
+
209
+ /** Seed a doc's counter from its real encoded size, e.g. after loading a snapshot. */
210
+ export function setDocBytes(docName, n) { docBytes.set(docName, Math.max(0, Number(n) || 0)) }
211
+
212
+ export function forgetDoc(docName) { docBytes.delete(docName) }
213
+
214
+ // ── per-connection rate limiting ────────────────────────────────────────────────────────
215
+ /**
216
+ * Attach buckets to one connection. Returns a `check(byteLength)` the message handler calls
217
+ * for every inbound frame: null to allow, or {reason} to refuse.
218
+ *
219
+ * State lives on the connection object, so it is freed with the socket and there is no map
220
+ * to leak or sweep.
221
+ */
222
+ export function attachRateLimit(conn, tier = null) {
223
+ const T = tier || LIMITS
224
+ const msgs = new Bucket(T.msgsPerSec, T.msgBurst)
225
+ const bytes = new Bucket(T.bytesPerSec, T.byteBurst)
226
+ const cooloff = T.cooloffMs || LIMITS.cooloffMs
227
+ const frameCap = T.maxFrameBytes || LIMITS.maxFrameBytes
228
+ let cooloffUntil = 0
229
+ let refusedCount = 0
230
+ // Usage, per connection. This is the raw material for the per-agent visibility below,
231
+ // and it is counted here because this is the only place every inbound frame passes.
232
+ let msgCount = 0, byteCount = 0, writeCount = 0, writeBytes = 0
233
+ const openedAt = Date.now()
234
+
235
+ conn._hiveLimit = {
236
+ tier: T.tier || 'flat',
237
+ seats: T.seats || null,
238
+ check(byteLength) {
239
+ const now = Date.now()
240
+ if (now < cooloffUntil) {
241
+ refusedCount++
242
+ return { reason: `Q432 over the message rate; backing off for ${Math.ceil((cooloffUntil - now) / 1000)}s` }
243
+ }
244
+ if (byteLength > frameCap) {
245
+ // Not a rate problem and no cooloff: one oversized frame is a bug in the client,
246
+ // and a cooloff would punish its next, valid message.
247
+ refusedCount++
248
+ return { reason: `Q433 frame of ${byteLength} bytes exceeds the ${frameCap}-byte limit` }
249
+ }
250
+ if (!msgs.take(1, now)) {
251
+ cooloffUntil = now + cooloff
252
+ refusedCount++
253
+ return { reason: `Q432 over the message rate (${T.msgsPerSec}/s sustained, ${T.msgBurst} burst${T.tier ? `, ${T.tier} tier` : ''})` }
254
+ }
255
+ if (!bytes.take(byteLength, now)) {
256
+ cooloffUntil = now + cooloff
257
+ refusedCount++
258
+ return { reason: `Q434 over the byte rate (${Math.round(T.bytesPerSec / 1024)} KB/s sustained${T.tier ? `, ${T.tier} tier` : ''})` }
259
+ }
260
+ msgCount++; byteCount += byteLength
261
+ return null
262
+ },
263
+ /** Called by the relay once it knows a passing frame was a write, not a read. */
264
+ noteWrite(byteLength) { writeCount++; writeBytes += byteLength },
265
+ get refused() { return refusedCount },
266
+ usage() {
267
+ return {
268
+ msgs: msgCount, bytes: byteCount,
269
+ writes: writeCount, writeBytes,
270
+ refused: refusedCount,
271
+ heldMs: Date.now() - openedAt,
272
+ }
273
+ },
274
+ }
275
+ return conn._hiveLimit
276
+ }
277
+
278
+ // ── PER-AGENT USAGE ─────────────────────────────────────────────────────────────────────
279
+ // "Cost visibility (per-agent token spend)" is a stated buyer requirement, and it needs one
280
+ // honest correction: THE RELAY CANNOT SEE TOKENS. An agent's LLM calls go to Anthropic or
281
+ // OpenAI directly and never touch this process, so any token number here would be invented.
282
+ //
283
+ // What the relay genuinely knows is ACTIVITY, and it is the more useful half for the problem
284
+ // people actually have: which agent is writing, how much, to which files, and who is sitting
285
+ // on a claim. That answers "why is this room slow", "which agent is hammering routes.js" and
286
+ // "what did each agent actually do" -- none of which a token count answers.
287
+ //
288
+ // Keyed by room+identity and kept in memory only. It is operational telemetry, not billing:
289
+ // billing is seats, which the licence record already decides.
290
+ const usage = new Map() // `${room}${identity}` -> {msgs, bytes, writes, writeBytes, refused, files:Set, sessions, lastAt}
291
+ const UKEY = (room, who) => `${room}${who}`
292
+
293
+ export function recordUsage(room, who, u, file = null) {
294
+ if (!room || !who || !u) return
295
+ const k = UKEY(room, who)
296
+ let e = usage.get(k)
297
+ if (!e) { e = { msgs: 0, bytes: 0, writes: 0, writeBytes: 0, refused: 0, files: new Set(), sessions: 0, heldMs: 0, lastAt: 0 }; usage.set(k, e) }
298
+ e.msgs += u.msgs || 0
299
+ e.bytes += u.bytes || 0
300
+ e.writes += u.writes || 0
301
+ e.writeBytes += u.writeBytes || 0
302
+ e.refused += u.refused || 0
303
+ e.heldMs += u.heldMs || 0
304
+ e.sessions += 1
305
+ if (file) e.files.add(file)
306
+ e.lastAt = Date.now()
307
+ }
308
+
309
+ /** Per-agent activity for one room, or every room when `room` is omitted. */
310
+ export function usageFor(room = null) {
311
+ const out = []
312
+ for (const [k, e] of usage) {
313
+ const [r, who] = k.split('')
314
+ if (room && r !== room) continue
315
+ out.push({
316
+ room: r, agent: who,
317
+ msgs: e.msgs, bytes: e.bytes,
318
+ writes: e.writes, writeBytes: e.writeBytes,
319
+ refused: e.refused,
320
+ files: e.files.size,
321
+ sessions: e.sessions,
322
+ connectedMs: e.heldMs,
323
+ lastAt: e.lastAt,
324
+ })
325
+ }
326
+ // Busiest writer first — the question this data exists to answer.
327
+ return out.sort((a, b) => b.writeBytes - a.writeBytes || b.writes - a.writes)
328
+ }
329
+
330
+ export function stats() {
331
+ return {
332
+ connections: totalConns,
333
+ rooms: roomConns.size,
334
+ largestDocBytes: docBytes.size ? Math.max(...docBytes.values()) : 0,
335
+ docsTracked: docBytes.size,
336
+ limits: LIMITS,
337
+ }
338
+ }
339
+
340
+ /** Test-only: reset module state between cases. */
341
+ export function _reset() {
342
+ totalConns = 0
343
+ roomConns.clear()
344
+ docBytes.clear()
345
+ usage.clear()
346
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.11.0",
4
- "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
3
+ "version": "0.11.2",
4
+ "description": "MCP server that lets any AI coding agent join a Hivecode room \u2014 humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/Ganesh-Kute/HiveCode",
@@ -27,8 +27,11 @@
27
27
  },
28
28
  "files": [
29
29
  "hive-mcp.js",
30
+ "hive-mailbox.js",
30
31
  "sync.js",
31
32
  "core.js",
33
+ "limits.js",
34
+ "grm-link.cjs",
32
35
  "token.js",
33
36
  "icr.js",
34
37
  "lang-js.js",
@@ -42,7 +45,7 @@
42
45
  "hive-coord.js",
43
46
  "substrate.js",
44
47
  "HIVE_RULES.md",
45
- "MCP.md",
48
+ "docs/MCP.md",
46
49
  "README.md",
47
50
  "LICENSE"
48
51
  ],
@@ -55,11 +58,12 @@
55
58
  "relay": "node server.js",
56
59
  "sync:engine": "node sync-engine.mjs",
57
60
  "check:engine": "node sync-engine.mjs --check",
58
- "pretest": "node scripts/sync-engine.mjs --check",
61
+ "pretest": "node scripts/sync-engine.mjs --check && node scripts/check-files.mjs",
59
62
  "test": "node tests/test.js",
63
+ "test:identity": "node tests/identity-audit-test.mjs",
60
64
  "test:billing": "node tests/billing-test.mjs",
61
65
  "test:license": "node tests/hive-license-test.js",
62
- "test:money": "node tests/billing-test.mjs && node tests/hive-license-test.js && node tests/recover-test.mjs",
66
+ "test:money": "node tests/payment-e2e-test.mjs && node tests/subscribe-test.mjs && node tests/lifecycle-test.mjs && node tests/billing-test.mjs && node tests/hive-license-test.js && node tests/recover-test.mjs",
63
67
  "check:web": "tsc --noEmit",
64
68
  "build:web": "esbuild web/src/main.ts --bundle --minify --format=iife --target=es2019 --outfile=public/app.js",
65
69
  "test:substrate": "node substrate-test.js",
@@ -68,7 +72,23 @@
68
72
  "test:substrate:authority": "node substrate-authority-test.js",
69
73
  "test:substrate:all": "node substrate-test.js && node substrate-live-test.js && node substrate-enforce-test.js && node substrate-authority-test.js",
70
74
  "test:recover": "node tests/recover-test.mjs",
71
- "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs"
75
+ "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs",
76
+ "test:limits": "node tests/limits-test.mjs",
77
+ "test:rejoin": "node tests/rejoin-clobber-test.mjs",
78
+ "test:prod": "node tests/test.js && node tests/firstrun-test.mjs && node tests/credential-split-test.mjs && node tests/limits-test.mjs && node tests/usage-meter-test.mjs && node tests/rejoin-clobber-test.mjs && node tests/durability-test.mjs && node tests/payment-e2e-test.mjs && node tests/subscribe-test.mjs && node tests/lifecycle-test.mjs && node tests/billing-test.mjs && node tests/hive-license-test.js && node tests/recover-test.mjs && node demo/wrap-test.mjs",
79
+ "test:meter": "node tests/usage-meter-test.mjs",
80
+ "test:subscribe": "node tests/subscribe-test.mjs",
81
+ "test:cred": "node tests/credential-split-test.mjs",
82
+ "test:firstrun": "node tests/firstrun-test.mjs",
83
+ "test:lifecycle": "node tests/lifecycle-test.mjs",
84
+ "test:payment": "node tests/payment-e2e-test.mjs",
85
+ "scorecard": "node scorecard.mjs",
86
+ "demo:video": "node demo/record.mjs",
87
+ "test:wrap": "node demo/wrap-test.mjs",
88
+ "demo:panel": "node demo/record-panel.mjs",
89
+ "demo:all": "node demo/record.mjs && node demo/record-panel.mjs",
90
+ "check:files": "node scripts/check-files.mjs",
91
+ "prepublishOnly": "node scripts/check-files.mjs"
72
92
  },
73
93
  "engines": {
74
94
  "node": ">=18"
@@ -94,4 +114,4 @@
94
114
  "test": "tests"
95
115
  },
96
116
  "author": "Ganesh Shivlal Kute <support@getgrm.tech>"
97
- }
117
+ }