hivecode-mcp 0.12.0 → 0.12.1
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/README.md +93 -280
- package/docs/MCP.md +18 -10
- package/docs/SELF_HOSTING.md +10 -7
- package/hive-mcp.js +108 -12
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-privacy.js +25 -0
- package/hive-relay-health.js +24 -4
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/limits.js +49 -1
- package/package.json +16 -7
- package/sync.js +106 -25
package/hive-update.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// hive-update.js -- tells an agent, and through it its human, that a newer hivecode is out.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. `npx -y hivecode-mcp@latest setup` points agent apps at a runtime COPY
|
|
4
|
+
// (~/.hivecode/runtime/<version>), which is what made starting fast, offline and free of npx.
|
|
5
|
+
// It also means nothing updates any more: every start used to fetch @latest, and now an agent
|
|
6
|
+
// stays on the version it was set up with until someone runs setup again. A room with mixed
|
|
7
|
+
// builds is the one condition hive_join itself warns can lose data, so the gap must be visible.
|
|
8
|
+
//
|
|
9
|
+
// IT NEVER GETS IN THE WAY. The check starts with the server, carries a short timeout, and
|
|
10
|
+
// hive_join and hive_status use whatever answer has arrived by then. A failed check says
|
|
11
|
+
// nothing -- no network is not news. HIVE_PRIVATE=1 and HIVE_NO_UPDATE_CHECK=1 both switch it
|
|
12
|
+
// off: a user who asked for privacy did not ask for a request to npm on every start.
|
|
13
|
+
import { VERSION } from './hive-version.js'
|
|
14
|
+
|
|
15
|
+
const REGISTRY_LATEST = 'https://registry.npmjs.org/hivecode-mcp/latest'
|
|
16
|
+
|
|
17
|
+
/** True when version `a` is newer than `b` (x.y.z; anything unparseable is never "newer"). */
|
|
18
|
+
export function newer(a, b) {
|
|
19
|
+
const parse = (v) => String(v).split('-')[0].split('.').map((n) => Number(n))
|
|
20
|
+
const pa = parse(a)
|
|
21
|
+
const pb = parse(b)
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const x = pa[i] ?? 0
|
|
24
|
+
const y = pb[i] ?? 0
|
|
25
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false
|
|
26
|
+
if (x !== y) return x > y
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let latest = null
|
|
32
|
+
let started = false
|
|
33
|
+
|
|
34
|
+
export function startUpdateCheck({ fetchImpl = globalThis.fetch, timeoutMs = 4000, env = process.env } = {}) {
|
|
35
|
+
if (started) return
|
|
36
|
+
started = true
|
|
37
|
+
if (env.HIVE_NO_UPDATE_CHECK === '1' || env.HIVE_PRIVATE === '1' || typeof fetchImpl !== 'function') return
|
|
38
|
+
Promise.resolve()
|
|
39
|
+
// HIVE_UPDATE_URL exists for tests/update-notice-test.mjs, which serves a fake registry so the
|
|
40
|
+
// wiring is proven end to end through a real server process.
|
|
41
|
+
.then(() => fetchImpl(env.HIVE_UPDATE_URL || REGISTRY_LATEST, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' } }))
|
|
42
|
+
.then((r) => (r && r.ok ? r.json() : null))
|
|
43
|
+
.then((j) => { if (j && typeof j.version === 'string') latest = j.version })
|
|
44
|
+
.catch(() => { /* offline, blocked, or slow: say nothing */ })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The line to show, or '' when this agent is current (or nobody knows yet). */
|
|
48
|
+
export function updateNotice(current = VERSION, known = latest) {
|
|
49
|
+
if (!known || !newer(known, current)) return ''
|
|
50
|
+
return `UPDATE AVAILABLE: hivecode ${known} is out; this agent runs ${current}. Tell your human to run ` +
|
|
51
|
+
'`npx -y hivecode-mcp@latest setup` and reload this agent. Mixed versions in one room can lose data.'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const _test = {
|
|
55
|
+
reset() { latest = null; started = false },
|
|
56
|
+
setLatest(v) { latest = v; started = true },
|
|
57
|
+
latest: () => latest,
|
|
58
|
+
}
|
package/limits.js
CHANGED
|
@@ -127,7 +127,27 @@ export const LIMITS = {
|
|
|
127
127
|
// Retained CRDT size for one doc. A doc is one file in this design.
|
|
128
128
|
maxDocBytes: num('HIVE_MAX_DOC_BYTES', 64 * 1024 * 1024),
|
|
129
129
|
maxConnsPerRoom: num('HIVE_MAX_CONNS_PER_ROOM', 64),
|
|
130
|
-
|
|
130
|
+
// The only cap that spans a whole project: a client holds one socket per open file (up to
|
|
131
|
+
// HIVE_MAX_OPEN_FILES=128) plus its base room, and each file is its own room as far as the
|
|
132
|
+
// per-room ceiling is concerned -- so a real team is bounded by THIS number and nothing else.
|
|
133
|
+
// It was 512: three participants on a 150-file room measured 409 connections, so a fourth
|
|
134
|
+
// was refused, on a relay already carrying other rooms. Measured cost 25 Sep 2026: ~56 KB of
|
|
135
|
+
// relay memory per connection (400 extra sockets to the same 400 docs: +22 MB), so 2048 is
|
|
136
|
+
// ~115 MB -- comfortable on a 512 MB free instance.
|
|
137
|
+
maxConnsTotal: num('HIVE_MAX_CONNS_TOTAL', 2048),
|
|
138
|
+
// ONE SOCKET PER PARTICIPANT (hive-mux-proto.js). A mux socket counts ONCE against
|
|
139
|
+
// maxConnsTotal above; each file it opens is a CHANNEL, counted against the file's per-room
|
|
140
|
+
// ceiling exactly as a per-file socket is, and against these two caps instead of the socket
|
|
141
|
+
// total -- which is the whole point: a participant stops costing 129 sockets.
|
|
142
|
+
//
|
|
143
|
+
// Sized by MEMORY, measured 25 Sep 2026: ~25.8 KB per channel whose doc no one else holds (the
|
|
144
|
+
// doc is most of it), ~2.4 KB for each further participant on the same doc. The socket cap
|
|
145
|
+
// used to bound open docs as a side effect; this is what bounds them now. 8192 lone small docs
|
|
146
|
+
// is ~211 MB on the 512 MB hosted instance -- raise it with the instance, not before. The per-
|
|
147
|
+
// participant cap matches the client's own bound in one-socket mode, so no single participant
|
|
148
|
+
// can take more than half the relay.
|
|
149
|
+
maxChannelsTotal: num('HIVE_MAX_CHANNELS_TOTAL', 8192),
|
|
150
|
+
maxChannelsPerSocket: num('HIVE_MUX_MAX_DOCS', 4096),
|
|
131
151
|
// How long a client stays refused after tripping a rate limit. Long enough that a tight
|
|
132
152
|
// loop actually yields, short enough that a human does not notice a one-off burst.
|
|
133
153
|
cooloffMs: num('HIVE_RATE_COOLOFF_MS', 5000),
|
|
@@ -183,6 +203,32 @@ export function noteConnectionOpened(room) {
|
|
|
183
203
|
roomConns.set(room, (roomConns.get(room) || 0) + 1)
|
|
184
204
|
}
|
|
185
205
|
|
|
206
|
+
// Channels share roomConns with sockets on purpose: the per-room ceiling means "how many
|
|
207
|
+
// participants hold this file", and it must mean that whichever transport each one uses.
|
|
208
|
+
let totalChannels = 0
|
|
209
|
+
export function admitChannel(room, tier = null) {
|
|
210
|
+
if (totalChannels >= LIMITS.maxChannelsTotal) {
|
|
211
|
+
return { code: 503, reason: `Q438 relay at capacity (${LIMITS.maxChannelsTotal} open files across all participants)` }
|
|
212
|
+
}
|
|
213
|
+
const cap = (tier && tier.maxConnsPerRoom) || LIMITS.maxConnsPerRoom
|
|
214
|
+
const n = roomConns.get(room) || 0
|
|
215
|
+
if (n >= cap) {
|
|
216
|
+
const which = tier ? `${tier.tier} tier` : 'this relay'
|
|
217
|
+
return { code: 429, reason: `Q431 room at capacity (${cap} connections on ${which})` }
|
|
218
|
+
}
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
export function noteChannelOpened(room) {
|
|
222
|
+
totalChannels++
|
|
223
|
+
roomConns.set(room, (roomConns.get(room) || 0) + 1)
|
|
224
|
+
}
|
|
225
|
+
export function noteChannelClosed(room) {
|
|
226
|
+
totalChannels = Math.max(0, totalChannels - 1)
|
|
227
|
+
const n = (roomConns.get(room) || 1) - 1
|
|
228
|
+
if (n <= 0) roomConns.delete(room)
|
|
229
|
+
else roomConns.set(room, n)
|
|
230
|
+
}
|
|
231
|
+
|
|
186
232
|
export function noteConnectionClosed(room) {
|
|
187
233
|
totalConns = Math.max(0, totalConns - 1)
|
|
188
234
|
const n = (roomConns.get(room) || 1) - 1
|
|
@@ -330,6 +376,7 @@ export function usageFor(room = null) {
|
|
|
330
376
|
export function stats() {
|
|
331
377
|
return {
|
|
332
378
|
connections: totalConns,
|
|
379
|
+
channels: totalChannels,
|
|
333
380
|
rooms: roomConns.size,
|
|
334
381
|
largestDocBytes: docBytes.size ? Math.max(...docBytes.values()) : 0,
|
|
335
382
|
docsTracked: docBytes.size,
|
|
@@ -340,6 +387,7 @@ export function stats() {
|
|
|
340
387
|
/** Test-only: reset module state between cases. */
|
|
341
388
|
export function _reset() {
|
|
342
389
|
totalConns = 0
|
|
390
|
+
totalChannels = 0
|
|
343
391
|
roomConns.clear()
|
|
344
392
|
docBytes.clear()
|
|
345
393
|
usage.clear()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hivecode-mcp",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"hive-mcp.js",
|
|
31
|
+
"hive-mux.js",
|
|
32
|
+
"hive-mux-proto.js",
|
|
31
33
|
"hive-version.js",
|
|
32
34
|
"hive-pr-staleness.js",
|
|
33
35
|
"hive-mailbox.js",
|
|
@@ -38,6 +40,9 @@
|
|
|
38
40
|
"hive-activity.js",
|
|
39
41
|
"hive-relay-health.js",
|
|
40
42
|
"hive-privacy.js",
|
|
43
|
+
"hive-setup.js",
|
|
44
|
+
"hive-stdio.js",
|
|
45
|
+
"hive-update.js",
|
|
41
46
|
"docs/SELF_HOSTING.md",
|
|
42
47
|
"reconcile.cjs",
|
|
43
48
|
"hive-busy.js",
|
|
@@ -80,13 +85,16 @@
|
|
|
80
85
|
"test:features:shape": "node tests/features-test.mjs --shape",
|
|
81
86
|
"test:learnings": "node tests/learnings-test.mjs",
|
|
82
87
|
"test:docs": "node tests/readme-promises-test.mjs",
|
|
88
|
+
"test:setup": "node tests/setup-test.mjs --package",
|
|
89
|
+
"test:mcp": "node tests/mcp-conformance-test.mjs",
|
|
83
90
|
"test:recreate": "node tests/blind-recreate-test.mjs",
|
|
84
91
|
"test:liveness": "node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs",
|
|
85
92
|
"build:ext": "node scripts/build-icr-cjs.cjs && node scripts/build-privacy-cjs.cjs",
|
|
86
93
|
"test:selfhost": "node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs",
|
|
87
|
-
"test:hardening": "node tests/genesis-claim-test.mjs && node tests/bare-root-write-test.mjs && node tests/version-truth-test.mjs && node tests/room-repo-drift-test.mjs && node tests/first-contact-refusal-test.mjs && node tests/open-file-cap-test.mjs && node tests/learnings-test.mjs && node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs && node tests/join-honesty-test.mjs && node tests/relay-diagnosis-test.mjs && node tests/blind-recreate-test.mjs && node tests/genesis-collision-test.mjs && node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs && node tests/readme-promises-test.mjs",
|
|
94
|
+
"test:hardening": "node tests/genesis-claim-test.mjs && node tests/bare-root-write-test.mjs && node tests/version-truth-test.mjs && node tests/room-repo-drift-test.mjs && node tests/first-contact-refusal-test.mjs && node tests/open-file-cap-test.mjs && node tests/learnings-test.mjs && node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs && node tests/join-honesty-test.mjs && node tests/relay-diagnosis-test.mjs && node tests/blind-recreate-test.mjs && node tests/genesis-collision-test.mjs && node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs && node tests/readme-promises-test.mjs && node tests/setup-test.mjs && node tests/mcp-conformance-test.mjs && node tests/update-notice-test.mjs && node tests/mux-test.mjs",
|
|
88
95
|
"test:extension": "node tests/extension-parity-test.mjs && node tests/extension-privacy-parity-test.mjs",
|
|
89
96
|
"test:ceiling": "node tests/refused-visible-test.mjs && node tests/ceiling-no-data-loss-test.mjs",
|
|
97
|
+
"test:mux": "node tests/mux-test.mjs",
|
|
90
98
|
"test:identity": "node tests/identity-audit-test.mjs",
|
|
91
99
|
"test:billing": "node tests/billing-test.mjs",
|
|
92
100
|
"test:license": "node tests/hive-license-test.js",
|
|
@@ -130,17 +138,18 @@
|
|
|
130
138
|
"node": ">=18"
|
|
131
139
|
},
|
|
132
140
|
"dependencies": {
|
|
133
|
-
"@anthropic-ai/sdk": "^0.104.2",
|
|
134
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
135
141
|
"acorn": "^8.17.0",
|
|
136
142
|
"ignore": "^7.0.5",
|
|
137
143
|
"lib0": "^0.2.117",
|
|
144
|
+
"lodash.debounce": "^4.0.8",
|
|
138
145
|
"ws": "^8.18.0",
|
|
139
|
-
"y-
|
|
140
|
-
"
|
|
141
|
-
"
|
|
146
|
+
"y-protocols": "^1.0.6",
|
|
147
|
+
"y-websocket": "^3.1.0",
|
|
148
|
+
"yjs": "^13.6.18"
|
|
142
149
|
},
|
|
143
150
|
"devDependencies": {
|
|
151
|
+
"@anthropic-ai/sdk": "^0.104.2",
|
|
152
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
144
153
|
"@types/node": "^26.1.2",
|
|
145
154
|
"esbuild": "^0.28.1",
|
|
146
155
|
"typescript": "^7.0.2"
|
package/sync.js
CHANGED
|
@@ -20,6 +20,7 @@ import os from 'os'
|
|
|
20
20
|
import path from 'path'
|
|
21
21
|
import * as Y from 'yjs'
|
|
22
22
|
import { WebsocketProvider } from 'y-websocket'
|
|
23
|
+
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
23
24
|
import { WebSocket } from 'ws'
|
|
24
25
|
import ignore from 'ignore'
|
|
25
26
|
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
@@ -41,7 +42,7 @@ import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from '.
|
|
|
41
42
|
// a debt written anywhere else would be invisible to hive_owed: a warning nobody can see, which is
|
|
42
43
|
// the defect this ticket is about, one level down.
|
|
43
44
|
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
44
|
-
import { checkRelay } from './hive-privacy.js' // HIV-P20; which relay is this, and did anybody choose it
|
|
45
|
+
import { checkRelay, currentRelayFor } from './hive-privacy.js' // HIV-P20; which relay is this, and did anybody choose it
|
|
45
46
|
import * as OB from './hive-obligations.js'
|
|
46
47
|
|
|
47
48
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
@@ -166,7 +167,7 @@ asked to collaborate here, follow this.
|
|
|
166
167
|
## Join the room
|
|
167
168
|
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
168
169
|
the Hivecode MCP server, then reload so the tools load:
|
|
169
|
-
claude mcp add hivecode -- npx -y hivecode-mcp
|
|
170
|
+
claude mcp add hivecode -- npx -y hivecode-mcp@latest
|
|
170
171
|
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
171
172
|
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
172
173
|
do NOT need a link — hive_join reads them automatically.
|
|
@@ -437,6 +438,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
437
438
|
// It throws for the reason the name check above throws: startSync has no failure channel,
|
|
438
439
|
// and a client that half-connects to a relay nobody authorised is the failure this exists
|
|
439
440
|
// to prevent. Refusing here means no socket was opened and no file was published.
|
|
441
|
+
// A retired public relay is followed to its replacement here too, because every node client
|
|
442
|
+
// (hive-agent.js, hive-say.js, the demos) reaches the network through this function and not
|
|
443
|
+
// through hive-mcp.js's resolveRoom. The privacy check below then judges the relay actually
|
|
444
|
+
// dialled, not the one that was asked for.
|
|
445
|
+
const moved = currentRelayFor(relay)
|
|
446
|
+
if (moved.migratedFrom) {
|
|
447
|
+
relay = moved.relay
|
|
448
|
+
try { log(`relay ${moved.migratedFrom} has been retired; using ${relay}`) } catch { /* log is optional */ }
|
|
449
|
+
}
|
|
440
450
|
const priv = checkRelay({ relay, env: process.env, dir: path.resolve(dir), fs })
|
|
441
451
|
if (!priv.ok) {
|
|
442
452
|
const e = new Error(`${priv.headline}\n\n${priv.detail}`)
|
|
@@ -968,6 +978,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
968
978
|
if (kind) wsParams.kind = kind
|
|
969
979
|
if (passive) wsParams.passive = 'true'
|
|
970
980
|
const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
|
|
981
|
+
// ONE SOCKET PER PARTICIPANT. When the relay offers it (it says so in /health), file docs stop
|
|
982
|
+
// costing a socket each: they ride one physical socket as channels, and the relay admits each
|
|
983
|
+
// one through the same gates a per-file socket passes. Against a relay that does not offer it,
|
|
984
|
+
// or with HIVE_MUX=0, every file gets its own WebsocketProvider exactly as before. Decided once,
|
|
985
|
+
// before the first file opens, so a session never straddles the two transports.
|
|
986
|
+
// onFallback: the relay stopped offering it mid-session, so trim to the per-file bound before
|
|
987
|
+
// each remaining file gets a socket of its own (trimForSockets, below).
|
|
988
|
+
const fileTransport = createFileTransport({ relay, room, wsOpts, log, onFallback: () => trimForSockets() })
|
|
989
|
+
let stopped = false
|
|
971
990
|
const provider = new WebsocketProvider(relay, room, doc, wsOpts)
|
|
972
991
|
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, passive: !!passive }) // identity is implicit in the client
|
|
973
992
|
|
|
@@ -1010,7 +1029,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1010
1029
|
`counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, ` +
|
|
1011
1030
|
`raise it with HIVE_SEATS; otherwise ask whoever does, or contact ${SUPPORT}.`
|
|
1012
1031
|
if (code === 503) return `the relay is not serving this connection. Either the whole relay is at capacity ` +
|
|
1013
|
-
`(Q430) or it could not check your licence and is failing closed rather than guessing. It may also be ` +
|
|
1032
|
+
`(Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be ` +
|
|
1014
1033
|
`restarting -- if this lasts, contact ${SUPPORT}.`
|
|
1015
1034
|
if (code) return `the relay answered ${code} instead of accepting the connection. Contact ${SUPPORT}.`
|
|
1016
1035
|
return `the relay at ${relay} cannot be reached. It may be down, asleep, or blocked by a network. ` +
|
|
@@ -1079,6 +1098,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1079
1098
|
// count is the useful shape -- one per file would be 800 lines of chat.
|
|
1080
1099
|
const refusedFiles = new Map() // relPath -> consecutive connection errors, cleared on sync
|
|
1081
1100
|
const refusedCodes = new Map() // relPath -> the HTTP status the relay refused with, when it gave one
|
|
1101
|
+
const refusedReasons = new Map() // relPath -> the relay's own words (a mux refusal carries its Q-code line)
|
|
1082
1102
|
let refusalSaidAt = 0
|
|
1083
1103
|
let refusalTimer = null
|
|
1084
1104
|
|
|
@@ -1125,7 +1145,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1125
1145
|
const code = coded.length ? coded.sort((a, b) => coded.filter((c) => c === b).length - coded.filter((c) => c === a).length)[0] : null
|
|
1126
1146
|
const msg = code
|
|
1127
1147
|
? `${stuck.length} file(s) are not synced and will READ AS EMPTY until this clears. ` +
|
|
1128
|
-
`The relay refused ${coded.length} of them with ${code}
|
|
1148
|
+
`The relay refused ${coded.length} of them with ${code}` +
|
|
1149
|
+
// The relay's own reason outranks any mapping of the bare number: one code covers
|
|
1150
|
+
// several ceilings, and only the relay knows which one it hit.
|
|
1151
|
+
(() => { const why = stuck.map((r) => refusedCodes.get(r) === code && refusedReasons.get(r)).find(Boolean); return why ? ` ("${String(why).slice(0, 160)}")` : '' })() +
|
|
1152
|
+
`: ${explain(code)} ` +
|
|
1129
1153
|
`Do not trust this folder's contents meanwhile.`
|
|
1130
1154
|
: (await RH.explainNotSyncing(relay, {
|
|
1131
1155
|
stuck: stuck.length, what: 'files',
|
|
@@ -1213,7 +1237,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1213
1237
|
let e = fileDocs.get(relPath)
|
|
1214
1238
|
if (e) return e
|
|
1215
1239
|
const fdoc = new Y.Doc()
|
|
1216
|
-
const fprovider =
|
|
1240
|
+
const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
|
|
1217
1241
|
const text = fdoc.getText('content')
|
|
1218
1242
|
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
1219
1243
|
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
@@ -1263,8 +1287,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1263
1287
|
// handler took no argument at all, so noteRefusals had nothing but "N files are quiet" and
|
|
1264
1288
|
// had to INFER a cause -- which is how it came to announce Q430 on 21 Sep while the relay
|
|
1265
1289
|
// sat at 56 of 512. explain() above maps these codes correctly and has done since 5 Sep.
|
|
1266
|
-
|
|
1290
|
+
// A mux refusal carries the code; a per-file socket's ErrorEvent carries it only in its
|
|
1291
|
+
// message, which is why this read nothing at all on the socket path.
|
|
1292
|
+
const code = RH.statusFromError(ev)
|
|
1267
1293
|
if (code) refusedCodes.set(relPath, code)
|
|
1294
|
+
if (ev && typeof ev.reason === 'string' && ev.reason) refusedReasons.set(relPath, ev.reason)
|
|
1268
1295
|
noteRefusals()
|
|
1269
1296
|
})
|
|
1270
1297
|
// Newly opened counts as used, and opening is the moment the working set can go over
|
|
@@ -1308,7 +1335,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1308
1335
|
// - a file in an unresolved conflict (its fork record is live and local-only)
|
|
1309
1336
|
// - a file anyone currently holds a claim on (they are mid-edit)
|
|
1310
1337
|
// - anything used within the grace window
|
|
1311
|
-
|
|
1338
|
+
//
|
|
1339
|
+
// THE BOUND DEPENDS ON THE TRANSPORT. With a socket per file it is what keeps one client from
|
|
1340
|
+
// filling the relay, and the rule above applies. In one-socket mode a file costs a channel, not
|
|
1341
|
+
// a socket, so the bound only caps memory -- set high enough that an ordinary repo is held
|
|
1342
|
+
// whole, which is what ends "this folder is incomplete on purpose". HIVE_MAX_OPEN_FILES wins
|
|
1343
|
+
// either way. A function, not a constant: the transport is known only once /health answers.
|
|
1344
|
+
const MAX_OPEN_SET = Number(process.env.HIVE_MAX_OPEN_FILES) || 0
|
|
1345
|
+
const maxOpen = () => Math.max(8, MAX_OPEN_SET || (fileTransport.mode === 'mux' ? 4096 : 128))
|
|
1312
1346
|
// Configurable because the grace window is what makes eviction untestable otherwise: in a
|
|
1313
1347
|
// short run every file was touched seconds ago, so nothing is ever eligible.
|
|
1314
1348
|
const EVICT_GRACE_MS = Number(process.env.HIVE_EVICT_GRACE_MS) || 30000
|
|
@@ -1316,7 +1350,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1316
1350
|
const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
|
|
1317
1351
|
let evicted = 0
|
|
1318
1352
|
function evictIfOverCap(keep) {
|
|
1319
|
-
if (fileDocs.size <=
|
|
1353
|
+
if (fileDocs.size <= maxOpen()) return
|
|
1320
1354
|
const now = Date.now()
|
|
1321
1355
|
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1322
1356
|
const cand = []
|
|
@@ -1330,7 +1364,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1330
1364
|
cand.push([r, t])
|
|
1331
1365
|
}
|
|
1332
1366
|
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1333
|
-
let over = fileDocs.size -
|
|
1367
|
+
let over = fileDocs.size - maxOpen()
|
|
1368
|
+
for (const [r] of cand) {
|
|
1369
|
+
if (over-- <= 0) break
|
|
1370
|
+
closeFile(r); touched.delete(r); evicted++
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// THE FALLBACK TRIM. The relay stopped offering one-socket mode mid-session, and every file still
|
|
1375
|
+
// open is about to cost a socket of its own. Keep the most recently used up to the per-file bound
|
|
1376
|
+
// and close the rest -- UNSYNCED ones too, unlike evictIfOverCap: on this path "unsynced" means
|
|
1377
|
+
// "was waiting on the socket that just went away", and its bytes are on disk or still in the
|
|
1378
|
+
// room, so closing it costs latency, not work. Conflicted and claimed files are kept whatever
|
|
1379
|
+
// the count, because closing those could. Without this, a joiner mid-way through fetching 900
|
|
1380
|
+
// files would open 900 sockets at once -- the storm the per-file bound exists to prevent.
|
|
1381
|
+
function trimForSockets() {
|
|
1382
|
+
const cap = maxOpen()
|
|
1383
|
+
if (fileDocs.size <= cap) return
|
|
1384
|
+
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1385
|
+
const cand = []
|
|
1386
|
+
for (const [r, e] of fileDocs) {
|
|
1387
|
+
if (conflicted.has(r) || isClaimed(r)) continue
|
|
1388
|
+
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1389
|
+
cand.push([r, touched.get(r) || 0])
|
|
1390
|
+
}
|
|
1391
|
+
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1392
|
+
let over = fileDocs.size - cap
|
|
1334
1393
|
for (const [r] of cand) {
|
|
1335
1394
|
if (over-- <= 0) break
|
|
1336
1395
|
closeFile(r); touched.delete(r); evicted++
|
|
@@ -2510,7 +2569,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2510
2569
|
// the sweep achieved nothing: the first measurement of this cap showed a client with
|
|
2511
2570
|
// cap 8 holding 30 file docs, every one of them opened right here. A path we decline
|
|
2512
2571
|
// to open now is not lost; it opens when something touches it.
|
|
2513
|
-
if (fileDocs.size <
|
|
2572
|
+
if (fileDocs.size < maxOpen()) openFile(key)
|
|
2514
2573
|
}
|
|
2515
2574
|
})
|
|
2516
2575
|
})
|
|
@@ -2559,16 +2618,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2559
2618
|
//
|
|
2560
2619
|
// Files that exist on THIS client's disk come first: they are the ones that need
|
|
2561
2620
|
// reconciling, and a path we have no local copy of can wait until something asks for it.
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2621
|
+
//
|
|
2622
|
+
// If /health has not said yet which transport this relay speaks, the sweep opens what is safe
|
|
2623
|
+
// on either (the per-file bound) now and the rest the moment it answers -- holding every file
|
|
2624
|
+
// back on one HTTP round trip would delay the join for nothing.
|
|
2625
|
+
const sweep = (final) => {
|
|
2626
|
+
const openable = [...manifest.keys()].filter((k) => canOpen(k) && !SKIP.has(k) && !isIgnored(k))
|
|
2627
|
+
const localFirst = openable.sort((a, b) => {
|
|
2628
|
+
const la = fs.existsSync(path.join(ROOT, a)) ? 0 : 1
|
|
2629
|
+
const lb = fs.existsSync(path.join(ROOT, b)) ? 0 : 1
|
|
2630
|
+
return la - lb
|
|
2631
|
+
})
|
|
2632
|
+
const cap = maxOpen()
|
|
2633
|
+
for (const key of localFirst.slice(0, cap)) openFile(key)
|
|
2634
|
+
if (final && localFirst.length > cap) {
|
|
2635
|
+
log(`[${name}] ${localFirst.length} files in this room, holding ${cap} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
|
|
2636
|
+
}
|
|
2571
2637
|
}
|
|
2638
|
+
const decided = fileTransport.mode !== 'detecting'
|
|
2639
|
+
sweep(decided)
|
|
2640
|
+
if (!decided) fileTransport.ready.then(() => { if (!stopped) sweep(true) })
|
|
2572
2641
|
if (board.size) renderBoard()
|
|
2573
2642
|
if (chat.length) renderChat()
|
|
2574
2643
|
if (tasks.size) renderTasks()
|
|
@@ -2587,20 +2656,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2587
2656
|
//
|
|
2588
2657
|
// Nothing here changes the cap. It tells the agent the number it needs in order to ASK,
|
|
2589
2658
|
// which is the one thing that makes "until something touches it" true.
|
|
2590
|
-
|
|
2659
|
+
//
|
|
2660
|
+
// Said only when the bound really leaves files out. Below it a missing file is in flight (or
|
|
2661
|
+
// refused, which noteRefusals reports with the relay's reason) -- not missing on purpose -- and
|
|
2662
|
+
// one-socket mode holds 4096 files, so an ordinary room never hears this. Timed from the
|
|
2663
|
+
// moment the transport is known, because the bound is not known before that.
|
|
2664
|
+
fileTransport.ready.then(() => setTimeout(() => {
|
|
2591
2665
|
try {
|
|
2592
2666
|
const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
|
|
2593
2667
|
const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
|
|
2594
2668
|
const short = want.length - have.length
|
|
2595
|
-
if (short <= 0) return
|
|
2669
|
+
if (short <= 0 || want.length <= maxOpen() || stopped) return
|
|
2596
2670
|
log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
|
|
2597
2671
|
`${have.length} are on disk here and ${short} are NOT. The working set is bounded to ` +
|
|
2598
|
-
`${
|
|
2599
|
-
`
|
|
2672
|
+
`${maxOpen()} open files (HIVE_MAX_OPEN_FILES), because ` +
|
|
2673
|
+
(fileTransport.mode === 'mux' ? `every open file costs relay memory. ` : `one socket is opened per file and a relay admits a limited number. `) +
|
|
2674
|
+
`A missing file EXISTS — it simply has not been fetched. ` +
|
|
2600
2675
|
`Do not treat it as absent and do not create it: reading or claiming the path fetches it. ` +
|
|
2601
2676
|
`hive_status reports filesHeld of roomFiles, and hive_read_board lists what the room holds.`)
|
|
2602
2677
|
} catch { }
|
|
2603
|
-
}, 4000).unref?.()
|
|
2678
|
+
}, 4000).unref?.())
|
|
2604
2679
|
if (!scanTimer) {
|
|
2605
2680
|
startWatch()
|
|
2606
2681
|
scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
|
|
@@ -2886,7 +2961,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2886
2961
|
// maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
|
|
2887
2962
|
// how this room actually works — both are things an operator can only act on if
|
|
2888
2963
|
// they can see them.
|
|
2889
|
-
openCap:
|
|
2964
|
+
openCap: maxOpen(),
|
|
2965
|
+
// 'mux' = every file on this client's one socket; 'socket' = a socket per file.
|
|
2966
|
+
transport: fileTransport.mode,
|
|
2890
2967
|
evicted,
|
|
2891
2968
|
}
|
|
2892
2969
|
},
|
|
@@ -2895,6 +2972,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2895
2972
|
if (debounce) clearTimeout(debounce)
|
|
2896
2973
|
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
2897
2974
|
try { watcher && watcher.close() } catch { }
|
|
2975
|
+
stopped = true
|
|
2976
|
+
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
2977
|
+
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
2978
|
+
try { fileTransport.stop() } catch { }
|
|
2898
2979
|
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
2899
2980
|
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
2900
2981
|
try { provider.destroy() } catch { }
|