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/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.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-websocket": "^2.0.4",
140
- "yjs": "^13.6.18",
141
- "zod": "^4.4.3"
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}: ${explain(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 = new WebsocketProvider(relay, fileRoom(room, relPath), fdoc, wsOpts)
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
- const code = ev && (ev.code || ev.status)
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
- const MAX_OPEN = Math.max(8, Number(process.env.HIVE_MAX_OPEN_FILES) || 128)
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 <= MAX_OPEN) return
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 - MAX_OPEN
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 // control files are ours and are supposed to be invisible
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 < MAX_OPEN) openFile(key)
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
- 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.`)
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
- setTimeout(() => {
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
- `${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. ` +
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: MAX_OPEN,
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 { }