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/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
- maxConnsTotal: num('HIVE_MAX_CONNS_TOTAL', 512),
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.0",
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-websocket": "^2.0.4",
140
- "yjs": "^13.6.18",
141
- "zod": "^4.4.3"
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}: ${explain(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 = new WebsocketProvider(relay, fileRoom(room, relPath), fdoc, wsOpts)
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
- const code = ev && (ev.code || ev.status)
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
- const MAX_OPEN = Math.max(8, Number(process.env.HIVE_MAX_OPEN_FILES) || 128)
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 <= MAX_OPEN) return
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 - MAX_OPEN
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 < MAX_OPEN) openFile(key)
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
- const openable = [...manifest.keys()].filter((k) => canOpen(k) && !SKIP.has(k) && !isIgnored(k))
2563
- const localFirst = openable.sort((a, b) => {
2564
- const la = fs.existsSync(path.join(ROOT, a)) ? 0 : 1
2565
- const lb = fs.existsSync(path.join(ROOT, b)) ? 0 : 1
2566
- return la - lb
2567
- })
2568
- for (const key of localFirst.slice(0, MAX_OPEN)) openFile(key)
2569
- if (localFirst.length > MAX_OPEN) {
2570
- log(`[${name}] ${localFirst.length} files in this room, holding ${MAX_OPEN} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
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
- setTimeout(() => {
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
- `${MAX_OPEN} open files (HIVE_MAX_OPEN_FILES), because one socket is opened per file and a ` +
2599
- `relay admits a limited number. A missing file EXISTS — it simply has not been fetched. ` +
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: MAX_OPEN,
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 { }