hivecode-mcp 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -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 +17 -7
- package/sync.js +113 -26
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.2",
|
|
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,17 @@
|
|
|
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 && node tests/own-files-quiet-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",
|
|
98
|
+
"test:live": "node scripts/live-stranger.mjs",
|
|
90
99
|
"test:identity": "node tests/identity-audit-test.mjs",
|
|
91
100
|
"test:billing": "node tests/billing-test.mjs",
|
|
92
101
|
"test:license": "node tests/hive-license-test.js",
|
|
@@ -130,17 +139,18 @@
|
|
|
130
139
|
"node": ">=18"
|
|
131
140
|
},
|
|
132
141
|
"dependencies": {
|
|
133
|
-
"@anthropic-ai/sdk": "^0.104.2",
|
|
134
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
135
142
|
"acorn": "^8.17.0",
|
|
136
143
|
"ignore": "^7.0.5",
|
|
137
144
|
"lib0": "^0.2.117",
|
|
145
|
+
"lodash.debounce": "^4.0.8",
|
|
138
146
|
"ws": "^8.18.0",
|
|
139
|
-
"y-
|
|
140
|
-
"
|
|
141
|
-
"
|
|
147
|
+
"y-protocols": "^1.0.6",
|
|
148
|
+
"y-websocket": "^3.1.0",
|
|
149
|
+
"yjs": "^13.6.18"
|
|
142
150
|
},
|
|
143
151
|
"devDependencies": {
|
|
152
|
+
"@anthropic-ai/sdk": "^0.104.2",
|
|
153
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
144
154
|
"@types/node": "^26.1.2",
|
|
145
155
|
"esbuild": "^0.28.1",
|
|
146
156
|
"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
|
|
@@ -63,6 +64,8 @@ const ALWAYS_IGNORE = [
|
|
|
63
64
|
// to tell an authored edit from its own write after a restart (see SHADOW_FILE). It is
|
|
64
65
|
// per-client by definition: syncing it would hand every peer a false ancestor.
|
|
65
66
|
'.hive-shadow.json',
|
|
67
|
+
// ...and its write-then-rename temp, which exists for an instant but is the same file.
|
|
68
|
+
'.hive-shadow.json.tmp',
|
|
66
69
|
// Names a PID on THIS machine. Syncing it would tell every peer their folder is locked by
|
|
67
70
|
// a process that does not exist for them.
|
|
68
71
|
'.hive-lock.json',
|
|
@@ -153,6 +156,10 @@ const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder
|
|
|
153
156
|
// Generated/coordination files are rendered locally from CRDT state — never
|
|
154
157
|
// synced as ordinary files (that would cause echo loops / conflicts).
|
|
155
158
|
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
|
|
159
|
+
// Files a client writes into its folder for ITSELF. They are ignored on purpose, so the "created
|
|
160
|
+
// here but is ignored" notice must never name them: on 27 Sep 2026 every agent joining a live room
|
|
161
|
+
// told the whole team about .hive-shadow.json, a file none of them had made.
|
|
162
|
+
const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-mcp-session.json', '.hive-id.json'])
|
|
156
163
|
|
|
157
164
|
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
158
165
|
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
@@ -166,7 +173,7 @@ asked to collaborate here, follow this.
|
|
|
166
173
|
## Join the room
|
|
167
174
|
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
168
175
|
the Hivecode MCP server, then reload so the tools load:
|
|
169
|
-
claude mcp add hivecode -- npx -y hivecode-mcp
|
|
176
|
+
claude mcp add hivecode -- npx -y hivecode-mcp@latest
|
|
170
177
|
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
171
178
|
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
172
179
|
do NOT need a link — hive_join reads them automatically.
|
|
@@ -437,6 +444,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
437
444
|
// It throws for the reason the name check above throws: startSync has no failure channel,
|
|
438
445
|
// and a client that half-connects to a relay nobody authorised is the failure this exists
|
|
439
446
|
// to prevent. Refusing here means no socket was opened and no file was published.
|
|
447
|
+
// A retired public relay is followed to its replacement here too, because every node client
|
|
448
|
+
// (hive-agent.js, hive-say.js, the demos) reaches the network through this function and not
|
|
449
|
+
// through hive-mcp.js's resolveRoom. The privacy check below then judges the relay actually
|
|
450
|
+
// dialled, not the one that was asked for.
|
|
451
|
+
const moved = currentRelayFor(relay)
|
|
452
|
+
if (moved.migratedFrom) {
|
|
453
|
+
relay = moved.relay
|
|
454
|
+
try { log(`relay ${moved.migratedFrom} has been retired; using ${relay}`) } catch { /* log is optional */ }
|
|
455
|
+
}
|
|
440
456
|
const priv = checkRelay({ relay, env: process.env, dir: path.resolve(dir), fs })
|
|
441
457
|
if (!priv.ok) {
|
|
442
458
|
const e = new Error(`${priv.headline}\n\n${priv.detail}`)
|
|
@@ -968,6 +984,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
968
984
|
if (kind) wsParams.kind = kind
|
|
969
985
|
if (passive) wsParams.passive = 'true'
|
|
970
986
|
const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
|
|
987
|
+
// ONE SOCKET PER PARTICIPANT. When the relay offers it (it says so in /health), file docs stop
|
|
988
|
+
// costing a socket each: they ride one physical socket as channels, and the relay admits each
|
|
989
|
+
// one through the same gates a per-file socket passes. Against a relay that does not offer it,
|
|
990
|
+
// or with HIVE_MUX=0, every file gets its own WebsocketProvider exactly as before. Decided once,
|
|
991
|
+
// before the first file opens, so a session never straddles the two transports.
|
|
992
|
+
// onFallback: the relay stopped offering it mid-session, so trim to the per-file bound before
|
|
993
|
+
// each remaining file gets a socket of its own (trimForSockets, below).
|
|
994
|
+
const fileTransport = createFileTransport({ relay, room, wsOpts, log, onFallback: () => trimForSockets() })
|
|
995
|
+
let stopped = false
|
|
971
996
|
const provider = new WebsocketProvider(relay, room, doc, wsOpts)
|
|
972
997
|
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, passive: !!passive }) // identity is implicit in the client
|
|
973
998
|
|
|
@@ -1010,7 +1035,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1010
1035
|
`counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, ` +
|
|
1011
1036
|
`raise it with HIVE_SEATS; otherwise ask whoever does, or contact ${SUPPORT}.`
|
|
1012
1037
|
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 ` +
|
|
1038
|
+
`(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
1039
|
`restarting -- if this lasts, contact ${SUPPORT}.`
|
|
1015
1040
|
if (code) return `the relay answered ${code} instead of accepting the connection. Contact ${SUPPORT}.`
|
|
1016
1041
|
return `the relay at ${relay} cannot be reached. It may be down, asleep, or blocked by a network. ` +
|
|
@@ -1079,6 +1104,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1079
1104
|
// count is the useful shape -- one per file would be 800 lines of chat.
|
|
1080
1105
|
const refusedFiles = new Map() // relPath -> consecutive connection errors, cleared on sync
|
|
1081
1106
|
const refusedCodes = new Map() // relPath -> the HTTP status the relay refused with, when it gave one
|
|
1107
|
+
const refusedReasons = new Map() // relPath -> the relay's own words (a mux refusal carries its Q-code line)
|
|
1082
1108
|
let refusalSaidAt = 0
|
|
1083
1109
|
let refusalTimer = null
|
|
1084
1110
|
|
|
@@ -1125,7 +1151,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1125
1151
|
const code = coded.length ? coded.sort((a, b) => coded.filter((c) => c === b).length - coded.filter((c) => c === a).length)[0] : null
|
|
1126
1152
|
const msg = code
|
|
1127
1153
|
? `${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}
|
|
1154
|
+
`The relay refused ${coded.length} of them with ${code}` +
|
|
1155
|
+
// The relay's own reason outranks any mapping of the bare number: one code covers
|
|
1156
|
+
// several ceilings, and only the relay knows which one it hit.
|
|
1157
|
+
(() => { const why = stuck.map((r) => refusedCodes.get(r) === code && refusedReasons.get(r)).find(Boolean); return why ? ` ("${String(why).slice(0, 160)}")` : '' })() +
|
|
1158
|
+
`: ${explain(code)} ` +
|
|
1129
1159
|
`Do not trust this folder's contents meanwhile.`
|
|
1130
1160
|
: (await RH.explainNotSyncing(relay, {
|
|
1131
1161
|
stuck: stuck.length, what: 'files',
|
|
@@ -1213,7 +1243,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1213
1243
|
let e = fileDocs.get(relPath)
|
|
1214
1244
|
if (e) return e
|
|
1215
1245
|
const fdoc = new Y.Doc()
|
|
1216
|
-
const fprovider =
|
|
1246
|
+
const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
|
|
1217
1247
|
const text = fdoc.getText('content')
|
|
1218
1248
|
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
1219
1249
|
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
@@ -1263,8 +1293,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1263
1293
|
// handler took no argument at all, so noteRefusals had nothing but "N files are quiet" and
|
|
1264
1294
|
// had to INFER a cause -- which is how it came to announce Q430 on 21 Sep while the relay
|
|
1265
1295
|
// sat at 56 of 512. explain() above maps these codes correctly and has done since 5 Sep.
|
|
1266
|
-
|
|
1296
|
+
// A mux refusal carries the code; a per-file socket's ErrorEvent carries it only in its
|
|
1297
|
+
// message, which is why this read nothing at all on the socket path.
|
|
1298
|
+
const code = RH.statusFromError(ev)
|
|
1267
1299
|
if (code) refusedCodes.set(relPath, code)
|
|
1300
|
+
if (ev && typeof ev.reason === 'string' && ev.reason) refusedReasons.set(relPath, ev.reason)
|
|
1268
1301
|
noteRefusals()
|
|
1269
1302
|
})
|
|
1270
1303
|
// Newly opened counts as used, and opening is the moment the working set can go over
|
|
@@ -1308,7 +1341,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1308
1341
|
// - a file in an unresolved conflict (its fork record is live and local-only)
|
|
1309
1342
|
// - a file anyone currently holds a claim on (they are mid-edit)
|
|
1310
1343
|
// - anything used within the grace window
|
|
1311
|
-
|
|
1344
|
+
//
|
|
1345
|
+
// THE BOUND DEPENDS ON THE TRANSPORT. With a socket per file it is what keeps one client from
|
|
1346
|
+
// filling the relay, and the rule above applies. In one-socket mode a file costs a channel, not
|
|
1347
|
+
// a socket, so the bound only caps memory -- set high enough that an ordinary repo is held
|
|
1348
|
+
// whole, which is what ends "this folder is incomplete on purpose". HIVE_MAX_OPEN_FILES wins
|
|
1349
|
+
// either way. A function, not a constant: the transport is known only once /health answers.
|
|
1350
|
+
const MAX_OPEN_SET = Number(process.env.HIVE_MAX_OPEN_FILES) || 0
|
|
1351
|
+
const maxOpen = () => Math.max(8, MAX_OPEN_SET || (fileTransport.mode === 'mux' ? 4096 : 128))
|
|
1312
1352
|
// Configurable because the grace window is what makes eviction untestable otherwise: in a
|
|
1313
1353
|
// short run every file was touched seconds ago, so nothing is ever eligible.
|
|
1314
1354
|
const EVICT_GRACE_MS = Number(process.env.HIVE_EVICT_GRACE_MS) || 30000
|
|
@@ -1316,7 +1356,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1316
1356
|
const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
|
|
1317
1357
|
let evicted = 0
|
|
1318
1358
|
function evictIfOverCap(keep) {
|
|
1319
|
-
if (fileDocs.size <=
|
|
1359
|
+
if (fileDocs.size <= maxOpen()) return
|
|
1320
1360
|
const now = Date.now()
|
|
1321
1361
|
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1322
1362
|
const cand = []
|
|
@@ -1330,7 +1370,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1330
1370
|
cand.push([r, t])
|
|
1331
1371
|
}
|
|
1332
1372
|
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1333
|
-
let over = fileDocs.size -
|
|
1373
|
+
let over = fileDocs.size - maxOpen()
|
|
1374
|
+
for (const [r] of cand) {
|
|
1375
|
+
if (over-- <= 0) break
|
|
1376
|
+
closeFile(r); touched.delete(r); evicted++
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// THE FALLBACK TRIM. The relay stopped offering one-socket mode mid-session, and every file still
|
|
1381
|
+
// open is about to cost a socket of its own. Keep the most recently used up to the per-file bound
|
|
1382
|
+
// and close the rest -- UNSYNCED ones too, unlike evictIfOverCap: on this path "unsynced" means
|
|
1383
|
+
// "was waiting on the socket that just went away", and its bytes are on disk or still in the
|
|
1384
|
+
// room, so closing it costs latency, not work. Conflicted and claimed files are kept whatever
|
|
1385
|
+
// the count, because closing those could. Without this, a joiner mid-way through fetching 900
|
|
1386
|
+
// files would open 900 sockets at once -- the storm the per-file bound exists to prevent.
|
|
1387
|
+
function trimForSockets() {
|
|
1388
|
+
const cap = maxOpen()
|
|
1389
|
+
if (fileDocs.size <= cap) return
|
|
1390
|
+
const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
|
|
1391
|
+
const cand = []
|
|
1392
|
+
for (const [r, e] of fileDocs) {
|
|
1393
|
+
if (conflicted.has(r) || isClaimed(r)) continue
|
|
1394
|
+
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1395
|
+
cand.push([r, touched.get(r) || 0])
|
|
1396
|
+
}
|
|
1397
|
+
cand.sort((a, b) => a[1] - b[1]) // least recently used first
|
|
1398
|
+
let over = fileDocs.size - cap
|
|
1334
1399
|
for (const [r] of cand) {
|
|
1335
1400
|
if (over-- <= 0) break
|
|
1336
1401
|
closeFile(r); touched.delete(r); evicted++
|
|
@@ -2417,7 +2482,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2417
2482
|
invisibleWarned.delete(r)
|
|
2418
2483
|
}
|
|
2419
2484
|
for (const r of ignoredRel) {
|
|
2420
|
-
if (SKIP.has(r)) continue
|
|
2485
|
+
if (SKIP.has(r) || OWN_LOCAL.has(r)) continue // control files are ours and are supposed to be invisible
|
|
2421
2486
|
if (preexisting.has(r)) continue // it was already here; nobody did this during the session
|
|
2422
2487
|
if (invisibleWarned.has(r)) continue // once per path, not once per scan tick
|
|
2423
2488
|
const holder = coordinator.sense(r)
|
|
@@ -2510,7 +2575,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2510
2575
|
// the sweep achieved nothing: the first measurement of this cap showed a client with
|
|
2511
2576
|
// cap 8 holding 30 file docs, every one of them opened right here. A path we decline
|
|
2512
2577
|
// to open now is not lost; it opens when something touches it.
|
|
2513
|
-
if (fileDocs.size <
|
|
2578
|
+
if (fileDocs.size < maxOpen()) openFile(key)
|
|
2514
2579
|
}
|
|
2515
2580
|
})
|
|
2516
2581
|
})
|
|
@@ -2559,16 +2624,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2559
2624
|
//
|
|
2560
2625
|
// Files that exist on THIS client's disk come first: they are the ones that need
|
|
2561
2626
|
// 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
|
-
|
|
2627
|
+
//
|
|
2628
|
+
// If /health has not said yet which transport this relay speaks, the sweep opens what is safe
|
|
2629
|
+
// on either (the per-file bound) now and the rest the moment it answers -- holding every file
|
|
2630
|
+
// back on one HTTP round trip would delay the join for nothing.
|
|
2631
|
+
const sweep = (final) => {
|
|
2632
|
+
const openable = [...manifest.keys()].filter((k) => canOpen(k) && !SKIP.has(k) && !isIgnored(k))
|
|
2633
|
+
const localFirst = openable.sort((a, b) => {
|
|
2634
|
+
const la = fs.existsSync(path.join(ROOT, a)) ? 0 : 1
|
|
2635
|
+
const lb = fs.existsSync(path.join(ROOT, b)) ? 0 : 1
|
|
2636
|
+
return la - lb
|
|
2637
|
+
})
|
|
2638
|
+
const cap = maxOpen()
|
|
2639
|
+
for (const key of localFirst.slice(0, cap)) openFile(key)
|
|
2640
|
+
if (final && localFirst.length > cap) {
|
|
2641
|
+
log(`[${name}] ${localFirst.length} files in this room, holding ${cap} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
|
|
2642
|
+
}
|
|
2571
2643
|
}
|
|
2644
|
+
const decided = fileTransport.mode !== 'detecting'
|
|
2645
|
+
sweep(decided)
|
|
2646
|
+
if (!decided) fileTransport.ready.then(() => { if (!stopped) sweep(true) })
|
|
2572
2647
|
if (board.size) renderBoard()
|
|
2573
2648
|
if (chat.length) renderChat()
|
|
2574
2649
|
if (tasks.size) renderTasks()
|
|
@@ -2587,20 +2662,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2587
2662
|
//
|
|
2588
2663
|
// Nothing here changes the cap. It tells the agent the number it needs in order to ASK,
|
|
2589
2664
|
// which is the one thing that makes "until something touches it" true.
|
|
2590
|
-
|
|
2665
|
+
//
|
|
2666
|
+
// Said only when the bound really leaves files out. Below it a missing file is in flight (or
|
|
2667
|
+
// refused, which noteRefusals reports with the relay's reason) -- not missing on purpose -- and
|
|
2668
|
+
// one-socket mode holds 4096 files, so an ordinary room never hears this. Timed from the
|
|
2669
|
+
// moment the transport is known, because the bound is not known before that.
|
|
2670
|
+
fileTransport.ready.then(() => setTimeout(() => {
|
|
2591
2671
|
try {
|
|
2592
2672
|
const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
|
|
2593
2673
|
const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
|
|
2594
2674
|
const short = want.length - have.length
|
|
2595
|
-
if (short <= 0) return
|
|
2675
|
+
if (short <= 0 || want.length <= maxOpen() || stopped) return
|
|
2596
2676
|
log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
|
|
2597
2677
|
`${have.length} are on disk here and ${short} are NOT. The working set is bounded to ` +
|
|
2598
|
-
`${
|
|
2599
|
-
`
|
|
2678
|
+
`${maxOpen()} open files (HIVE_MAX_OPEN_FILES), because ` +
|
|
2679
|
+
(fileTransport.mode === 'mux' ? `every open file costs relay memory. ` : `one socket is opened per file and a relay admits a limited number. `) +
|
|
2680
|
+
`A missing file EXISTS — it simply has not been fetched. ` +
|
|
2600
2681
|
`Do not treat it as absent and do not create it: reading or claiming the path fetches it. ` +
|
|
2601
2682
|
`hive_status reports filesHeld of roomFiles, and hive_read_board lists what the room holds.`)
|
|
2602
2683
|
} catch { }
|
|
2603
|
-
}, 4000).unref?.()
|
|
2684
|
+
}, 4000).unref?.())
|
|
2604
2685
|
if (!scanTimer) {
|
|
2605
2686
|
startWatch()
|
|
2606
2687
|
scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
|
|
@@ -2886,7 +2967,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2886
2967
|
// maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
|
|
2887
2968
|
// how this room actually works — both are things an operator can only act on if
|
|
2888
2969
|
// they can see them.
|
|
2889
|
-
openCap:
|
|
2970
|
+
openCap: maxOpen(),
|
|
2971
|
+
// 'mux' = every file on this client's one socket; 'socket' = a socket per file.
|
|
2972
|
+
transport: fileTransport.mode,
|
|
2890
2973
|
evicted,
|
|
2891
2974
|
}
|
|
2892
2975
|
},
|
|
@@ -2895,6 +2978,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2895
2978
|
if (debounce) clearTimeout(debounce)
|
|
2896
2979
|
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
2897
2980
|
try { watcher && watcher.close() } catch { }
|
|
2981
|
+
stopped = true
|
|
2982
|
+
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
2983
|
+
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
2984
|
+
try { fileTransport.stop() } catch { }
|
|
2898
2985
|
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
2899
2986
|
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
2900
2987
|
try { provider.destroy() } catch { }
|