hivecode-mcp 0.11.5 → 0.12.0

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/sync.js CHANGED
@@ -25,7 +25,9 @@ import ignore from 'ignore'
25
25
  import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
26
26
  import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
27
27
  import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
28
+ import { gateConfig, runGate, verdictRecord, refusalMessage, forcedMessage } from './hive-gate.js' // the HANDOFF gate: a claim you cannot give back while your own check fails
28
29
  import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
30
+ import { validateMemberName } from './hive-mailbox.js' // a name the mention parser cannot produce is not a name
29
31
  import { genIdentity, authorChange, verifyReceipt, contentHash } from './substrate.js' // convergent-substrate: signed provenance for every landed change
30
32
  // `languageFor` belongs in this list and was missing: reconcile() calls it on the
31
33
  // clean-rewrite path, so a local whole-file rewrite of a supported file threw
@@ -34,6 +36,13 @@ import { genIdentity, authorChange, verifyReceipt, contentHash } from './substra
34
36
  // whole file is the most common thing an agent does, so this fired constantly.
35
37
  import grmLink from './grm-link.cjs' // hands this room's history to GRM, so the ROOM has a memory
36
38
  import { structuralMerge, supports, languageFor } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
39
+ import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from './hive-pr-staleness.js' // room-versus-branch provenance staleness checks (HIV-P9)
40
+ // HIV-P8. The map key MUST be the string 'obligations' — hive-mcp.js:656 reads that exact key, and
41
+ // a debt written anywhere else would be invisible to hive_owed: a warning nobody can see, which is
42
+ // the defect this ticket is about, one level down.
43
+ 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 * as OB from './hive-obligations.js'
37
46
 
38
47
  // Wire-format/protocol version. Bump when a change makes clients incompatible
39
48
  // (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
@@ -78,6 +87,52 @@ const ALWAYS_IGNORE = [
78
87
  '__pycache__/', '*.pyc', '.pytest_cache/', '.mypy_cache/', '.venv/',
79
88
  ]
80
89
 
90
+ // Is this "merge" actually a FUSION — output neither author wrote? Returns the reason, or ''.
91
+ //
92
+ // Exported and pure so it can be tested against crafted inputs. Forcing a real fusion is
93
+ // timing-dependent (it needs concurrent writes landing inside the sync window), and a guard
94
+ // this important should not be covered only by a test that is inherently flaky.
95
+ //
96
+ // The two signals, and why each is here:
97
+ // - BROKE IT: both versions parsed and the merged output does not. There is no reading of
98
+ // that which is correct — whatever it produced, neither author wrote it.
99
+ // - DUPLICATION: distinctive lines appear MORE often in the output than in the side they
100
+ // came from. That is content copied rather than merged, and it is what a union of two
101
+ // near-identical versions looks like — the live corruption had `const MAX_OPEN` and
102
+ // `function evictIfOverCap` three times each.
103
+ //
104
+ // LENGTH IS DELIBERATELY NOT A SIGNAL, and the first version of this got that wrong. "The
105
+ // output is about as long as both inputs combined" describes a fusion, but it equally
106
+ // describes two authors who each added two hundred lines in different places — a perfectly
107
+ // good merge. The test caught it firing on exactly that. Duplication separates them: a
108
+ // genuine disjoint merge repeats nothing.
109
+ export function fusionReason(merged, mine, theirs, lang) {
110
+ if (!merged || !mine || !theirs || mine === theirs) return ''
111
+ // If a side was ALREADY broken, the merge did not break it — blaming the merge would make
112
+ // every merge on a half-written file look like corruption.
113
+ if (lang && lang.parses(mine) && lang.parses(theirs) && !lang.parses(merged)) {
114
+ return 'the merged result does not parse, although BOTH versions did'
115
+ }
116
+ // Only distinctive lines: a closing brace or a blank line legitimately repeats, and
117
+ // counting those would make every file look duplicated.
118
+ const sig = (s) => s.split('\n').map((l) => l.trim()).filter((l) => l.length > 24)
119
+ const tally = (arr) => { const m = new Map(); for (const l of arr) m.set(l, (m.get(l) || 0) + 1); return m }
120
+ const M = tally(sig(merged)), A = tally(sig(mine)), B = tally(sig(theirs))
121
+ let dup = 0, seen = 0
122
+ for (const [line, n] of M) {
123
+ const expected = Math.max(A.get(line) || 0, B.get(line) || 0)
124
+ if (expected === 0) continue // content in neither side — new, and not our business here
125
+ seen++
126
+ if (n > expected) dup++
127
+ }
128
+ // Needs enough lines to mean anything, and enough of them repeated to be structural rather
129
+ // than one unlucky duplicated statement.
130
+ if (seen >= 10 && dup / seen >= 0.25) {
131
+ return `${dup} of ${seen} distinctive lines appear more often in the result than in either version — that is content duplicated, not merged`
132
+ }
133
+ return ''
134
+ }
135
+
81
136
  const IGNORE = new Set(['node_modules', '.git'])
82
137
  const MAX_BYTES = 1_000_000
83
138
  const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never synced as a file
@@ -89,10 +144,15 @@ const LOCK_FILE = '.hive-lock.json' // one sync client per folder; see acqui
89
144
  const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
90
145
  const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
91
146
  const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
147
+ // The claim map is the stigmergic trace agents SENSE before editing (see `claims` below), but it
148
+ // was the one part of the room that never reached disk — so an agent could only learn it by
149
+ // remembering to call a tool, and a trace nobody can see coordinates nothing. Rendered here for
150
+ // the same reason HIVE_CHAT.md is: a file in the working folder needs no tool call and no memory.
151
+ const CLAIMS_FILE = 'HIVE_CLAIMS.md'
92
152
  const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
93
153
  // Generated/coordination files are rendered locally from CRDT state — never
94
154
  // synced as ordinary files (that would cause echo loops / conflicts).
95
- const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
155
+ const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
96
156
 
97
157
  // Written into every room folder on sync (like the rules). An AI agent that opens
98
158
  // this project reads it to learn it's a Hivecode room and how to JOIN — via the
@@ -118,7 +178,9 @@ asked to collaborate here, follow this.
118
178
  hive_release it. If you DON'T, someone else holds it — pick other work
119
179
  (hive_claims() shows what's open). This is how agents avoid colliding.
120
180
  - hive_say("taking X: doing Y") to announce intent before you edit.
121
- - ADDRESS EVERY MESSAGE. Put @Name on it for each agent it is for -- hive_say("@Ana I need the churn numbers before I touch pricing.md"). Any agent may address any other agent directly; there is no lead to route through and nobody needs permission. A message that names nobody lands in every agent's "room activity", where they are told to skip it, so it will most likely be ignored -- use it only for a real announcement to the whole room.
181
+ - ADDRESS EVERY MESSAGE. Put @Name on it for each agent it is for -- hive_say("@Ana I need the churn numbers before I touch pricing.md"). Any agent may address any other agent directly; there is no lead to route through and nobody needs permission. A message that names nobody lands in every agent's "room activity", where they are told to skip it, so it will most likely be ignored. Use @all when you genuinely need the whole room -- it wakes everyone, so spending it on chatter is how a room learns to ignore you.
182
+ - ANSWER EVERY QUESTION AIMED AT YOU. "Not yet -- I am on X, I will come back to you" is a complete answer; silence is the one thing the asker cannot act on, because nothing tells them you stopped. hive_check lists anyone still waiting on you.
183
+ - BEFORE A LONG STRETCH OF WORK, call hive_busy("what you are doing"). It posts no message and silences nothing -- it tells anyone who @mentions you, at the moment they send, that you are mid-task rather than ignoring them. hive_busy(clear:true) when you finish.
122
184
  - Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
123
185
  - Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
124
186
  - A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
@@ -152,6 +214,19 @@ another's work. The sync layer enforces the hard parts automatically; you do the
152
214
  ## Know who you are working with
153
215
  - Read HIVE_MEMBERS.md — who (humans + AIs) is in the room right now, and how many.
154
216
 
217
+ ## DESIGN BEFORE YOU WRITE — mandatory (Ganesh, 19 Sep 2026)
218
+ Before you build any new feature, post in the room, BEFORE your first edit:
219
+ 1. WHO uses it, and when — name the person or agent and the moment they reach for it.
220
+ 2. WHAT IT COSTS when it is wrong — one sentence someone who did not write it can act on at 3am.
221
+ 3. EVERY EDGE CASE you can name — empty, missing, offline, refused, concurrent, already-done,
222
+ called twice, called by the wrong person.
223
+ Then build it.
224
+ This is not process for its own sake. Every defect this project has produced has been a FALSE
225
+ SUCCESS — nothing crashed, every signal was green, the result was wrong. That class is never
226
+ caught by testing the happy path afterwards; it is caught by asking first what "wrong but green"
227
+ would look like for this particular feature. If you cannot say what breaks, you do not understand
228
+ it well enough to write it — say so in the room and ask. That is a complete answer.
229
+
155
230
  ## Before you touch a file
156
231
  1. Read HIVE_CHAT.md — what is everyone doing right now.
157
232
  2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
@@ -337,12 +412,42 @@ export function acquireFolderLock(root, room, log = console.log) {
337
412
  return release
338
413
  }
339
414
 
340
- export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true }) {
415
+ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false }) {
416
+ // BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
417
+ // then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
418
+ // and the only symptom is other people being told "@X is NOT in the room" about someone who is
419
+ // sitting right there. Refusing here costs the caller one clear error; allowing it cost an hour
420
+ // on 20 Sep 2026 and was only found because a human noticed the routing failures by eye.
421
+ // This throws rather than returning, because startSync has no failure channel and a half-joined
422
+ // client is worse than no client.
423
+ const nameCheck = validateMemberName(name)
424
+ if (!nameCheck.ok) {
425
+ const e = new Error(`${nameCheck.error} Try "${nameCheck.suggested}".`)
426
+ e.code = 'EHIVEINVALIDNAME'
427
+ e.suggested = nameCheck.suggested
428
+ throw e
429
+ }
430
+ // THE SAME PRIVACY GATE hive-mcp.js applies at join, repeated here because this is the
431
+ // real choke point. Every client in the product -- the MCP server, hive-agent.js, the
432
+ // CLIs, every demo script -- reaches the network through this one function, and a guard
433
+ // that lives only in the MCP layer protects only agents that went through the MCP layer.
434
+ // An operator who sets HIVE_RELAY_PIN is entitled to expect it means "this machine",
435
+ // not "this machine, except for the seven scripts that call startSync directly".
436
+ //
437
+ // It throws for the reason the name check above throws: startSync has no failure channel,
438
+ // and a client that half-connects to a relay nobody authorised is the failure this exists
439
+ // to prevent. Refusing here means no socket was opened and no file was published.
440
+ const priv = checkRelay({ relay, env: process.env, dir: path.resolve(dir), fs })
441
+ if (!priv.ok) {
442
+ const e = new Error(`${priv.headline}\n\n${priv.detail}`)
443
+ e.code = 'EHIVERELAYPIN'
444
+ e.qcode = priv.code
445
+ throw e
446
+ }
341
447
  const ROOT = path.resolve(dir)
342
448
  fs.mkdirSync(ROOT, { recursive: true })
343
449
  // FIRST, before any socket or file work: if another client already holds this folder,
344
- // nothing below should happen at all.
345
- const releaseLock = acquireFolderLock(ROOT, room, log)
450
+ const releaseLock = syncFiles ? acquireFolderLock(ROOT, room, log) : () => {}
346
451
 
347
452
  // Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
348
453
  // teammate's .env, keys, or build output. Reloaded by reloadIgnores().
@@ -431,6 +536,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
431
536
  // DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
432
537
  // whichever peer wrote last, which is precisely the failure it exists to catch.
433
538
  const swarmAttest = doc.getMap('swarm_attest')
539
+ // GATE VERDICTS — `<region>|<peerName>` -> the result of that peer running the room's check in
540
+ // ITS OWN folder before handing the file back. Keyed per peer for the same reason swarmAttest
541
+ // is: "the tests pass" is a fact about one folder's bytes, and storing it as a single shared
542
+ // value would converge on whoever released last, silently erasing the disagreement that is the
543
+ // entire signal. Two agents reporting different verdicts for one path both survive here.
544
+ const gateResults = doc.getMap('gate_results')
434
545
  // CLAIM LEASE. Expiry is evaluated lazily on read, so the moment a lease passes its TTL the
435
546
  // next agent that tries simply gets the file -- that is what stops a stalled agent
436
547
  // deadlocking the room.
@@ -855,9 +966,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
855
966
  if (lic) wsParams.license = lic
856
967
  if (name) wsParams.name = String(name).slice(0, 48)
857
968
  if (kind) wsParams.kind = kind
969
+ if (passive) wsParams.passive = 'true'
858
970
  const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
859
971
  const provider = new WebsocketProvider(relay, room, doc, wsOpts)
860
- provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION }) // identity is implicit in the client
972
+ provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, passive: !!passive }) // identity is implicit in the client
861
973
 
862
974
  // ---- relay reachability, said out loud exactly once -------------------------------
863
975
  // y-websocket retries forever and silently. That is the right RECOVERY behaviour and
@@ -955,6 +1067,148 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
955
1067
  // "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
956
1068
  // would create a second CRDT history and duplicate/garble content on rejoin).
957
1069
  const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
1070
+
1071
+ // REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
1072
+ // silence. That is right for a flaky network and badly wrong for a relay that is full: on
1073
+ // 18 Sep 2026 a relay answered 38,011 connections with Q430 while the agents involved
1074
+ // showed no symptom at all. Their files simply never arrived, and an unsynced file-doc
1075
+ // reads as EMPTY, which is how the same evening ended with 186 files written to 0 bytes.
1076
+ //
1077
+ // So count refusals for docs that have NEVER synced, and say it once. The condition is
1078
+ // usually a whole-relay problem rather than a per-file one, so one message naming the
1079
+ // count is the useful shape -- one per file would be 800 lines of chat.
1080
+ const refusedFiles = new Map() // relPath -> consecutive connection errors, cleared on sync
1081
+ const refusedCodes = new Map() // relPath -> the HTTP status the relay refused with, when it gave one
1082
+ let refusalSaidAt = 0
1083
+ let refusalTimer = null
1084
+
1085
+ function noteRefusals() {
1086
+ if (refusalTimer) return
1087
+ // Debounced: a full relay refuses every file at once, and we want the total, not the first.
1088
+ refusalTimer = setTimeout(async () => {
1089
+ refusalTimer = null
1090
+ const stuck = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
1091
+ // One transient error on one file is a blip, not an outage. Two or more files stuck, or
1092
+ // repeated failures on one, is the shape of a relay that will not take us.
1093
+ const repeated = stuck.some((r) => (refusedFiles.get(r) || 0) >= 3)
1094
+ if (stuck.length < 2 && !repeated) return
1095
+ const now = Date.now()
1096
+ if (now - refusalSaidAt < 300000) return // at most once every 5 minutes
1097
+ refusalSaidAt = now
1098
+ // HIV-P13. THIS MESSAGE USED TO NAME A CAUSE IT HAD NOT MEASURED.
1099
+ //
1100
+ // It said "the usual cause is the relay being full (Q430)" on the strength of having
1101
+ // observed only that N file docs have synced=false. Measured 21 Sep 2026, at the moment a
1102
+ // third agent printed it for 49 files: the relay reported 56 connections of 512, and
1103
+ // limits.js keys its per-room count on the FILE-room, so any one file held three
1104
+ // connections against a cap of 64. Q430 needed 512 and had 56; Q431 needed 64 and had 3.
1105
+ // Neither refusal could have fired. The real event was the relay dropping its room state
1106
+ // -- /health went rooms 44 -> 0 -> 29 -> 44 over a few minutes -- and every client
1107
+ // reconnecting one socket per file.
1108
+ //
1109
+ // "The relay is full" and "the relay just dropped everyone and we are all reconnecting"
1110
+ // produce an identical symptom and need OPPOSITE responses: a config change, or thirty
1111
+ // seconds of patience. Telling an operator to raise a limit that is 89% free is worse
1112
+ // than saying nothing, because they will do it, see no change, and conclude the product
1113
+ // is broken -- correctly, on the evidence they were given.
1114
+ //
1115
+ // So the client asks the relay instead of guessing, and reports what came back. It also
1116
+ // stops printing the literal string "<relay-http>", which it did for three weeks while
1117
+ // holding the relay URL in scope.
1118
+ // EVIDENCE BEFORE INFERENCE. If the relay actually refused us it said so with a status,
1119
+ // and explain() maps those to the right cause and the right knob -- Q431 to HIVE_SEATS,
1120
+ // Q430 to capacity, 401/403 to the token, 402 to the plan. That is a fact from the relay.
1121
+ // Only when nothing refused us -- the sockets simply never came up, which is what a
1122
+ // dropped-and-reconnecting relay looks like -- is there anything left to infer, and then
1123
+ // we go and ASK rather than assert.
1124
+ const coded = stuck.map((r) => refusedCodes.get(r)).filter(Boolean)
1125
+ const code = coded.length ? coded.sort((a, b) => coded.filter((c) => c === b).length - coded.filter((c) => c === a).length)[0] : null
1126
+ const msg = code
1127
+ ? `${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)} ` +
1129
+ `Do not trust this folder's contents meanwhile.`
1130
+ : (await RH.explainNotSyncing(relay, {
1131
+ stuck: stuck.length, what: 'files',
1132
+ trailer: `Nothing refused these connections with a status code, so the cause above is ` +
1133
+ `inferred from the relay's own counters rather than reported by it. ` +
1134
+ `Do not trust this folder's contents meanwhile.`,
1135
+ })).text
1136
+ log(`[${name}] !! ${msg}`)
1137
+ try { say(`[!] @all ${msg}`) } catch { /* chat may itself be unreachable — the log still carries it */ }
1138
+ }, 4000)
1139
+ if (refusalTimer.unref) refusalTimer.unref()
1140
+ }
1141
+ // HIV-P18. THE DOCUMENTED REMEDY DID NOT EXIST.
1142
+ //
1143
+ // The README told every user: "A file outside the working set is not on your disk yet. It
1144
+ // exists in the room. Reading or claiming the path fetches it." Measured 21 Sep 2026 against
1145
+ // a client holding 8 of a room's 41 files, on a path that WAS in the manifest:
1146
+ //
1147
+ // whyNotShared(mod024.js) -> "" (in scope; edits would reach the room)
1148
+ // claim(mod024.js) -> true ("it's yours")
1149
+ // after claim + 9s -> on disk = false
1150
+ // after release + 9s -> on disk = false
1151
+ // provenanceOf -> 0 receipts, headOf -> null (doc never opened)
1152
+ // public fetch/read/open -> undefined
1153
+ //
1154
+ // Nothing brought the bytes down. The cause is one line in reconcile: `if (disk === null)
1155
+ // return` before openFile, plus a warm-up that only opens files already on local disk. A
1156
+ // manifest entry with no local copy was never opened, so it could never arrive.
1157
+ //
1158
+ // The only thing that DID work was writing to the path -- which is precisely the blind
1159
+ // recreate HIV-P14b guards. So the sole recovery route was the one dangerous action, reached
1160
+ // by an agent that had been told "it's yours" and then saw an empty directory. That is
1161
+ // HIV-P3's class arriving inbound: a claim granted over bytes that cannot move.
1162
+ //
1163
+ // This makes the sentence true. `fetch` opens the doc and RESOLVES WHEN THE CONTENT HAS
1164
+ // LANDED, because an un-awaited open is the same false success one layer down: the caller
1165
+ // reads the directory, sees nothing, and concludes the file is absent.
1166
+ function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
1167
+ const r = String(relPath)
1168
+ if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
1169
+ const full = path.join(ROOT, r)
1170
+ // Already here: report it, and say so rather than implying we went and got it.
1171
+ if (fs.existsSync(full)) return Promise.resolve({ ok: true, alreadyLocal: true, file: r })
1172
+ // Not in the room either. This is the case @Hermes hit, and it is NOT the working-set
1173
+ // case: nothing will ever arrive, so saying "fetching" would be a lie. The two look
1174
+ // identical from an empty directory and have opposite remedies, so they get different
1175
+ // answers here.
1176
+ if (!manifest.has(r)) {
1177
+ return Promise.resolve({
1178
+ ok: false, notInRoom: true, file: r,
1179
+ reason: `${r} is not in this room at all — no client has ever published it, so nothing ` +
1180
+ `can fetch it. This is not a working-set miss. Check the room's .gitignore allow-list ` +
1181
+ `covers the path, then have whoever holds the file write it into their synced folder.`,
1182
+ })
1183
+ }
1184
+ const e = openFile(r)
1185
+ if (!e) return Promise.resolve({ ok: false, reason: `could not open ${r}` })
1186
+ return new Promise((resolve) => {
1187
+ let done = false
1188
+ const finish = (res) => { if (done) return; done = true; clearInterval(iv); clearTimeout(to); resolve(res) }
1189
+ const check = () => {
1190
+ if (!e.synced) return
1191
+ // reconcile() runs on the provider's own sync event and writes to disk. Wait for the
1192
+ // BYTES, not for the flag -- the flag says the socket settled, which is not the claim
1193
+ // being made to the caller.
1194
+ if (fs.existsSync(full)) return finish({ ok: true, file: r, bytes: fs.statSync(full).size })
1195
+ // Synced and still nothing: the room holds this path with empty content. Real, and a
1196
+ // different answer from "not fetched yet".
1197
+ if (e.text.toString() === '') return finish({ ok: true, file: r, empty: true, bytes: 0 })
1198
+ }
1199
+ const iv = setInterval(check, 250)
1200
+ if (iv.unref) iv.unref()
1201
+ const to = setTimeout(() => finish({
1202
+ ok: false, file: r, timedOut: true,
1203
+ reason: `${r} is in the room but did not arrive within ${timeoutMs}ms. The file's own ` +
1204
+ `socket may be refused — hive_status reports filesHeld of roomFiles, and a refused ` +
1205
+ `file doc is indistinguishable from an empty file.`,
1206
+ }), timeoutMs)
1207
+ if (to.unref) to.unref()
1208
+ check()
1209
+ })
1210
+ }
1211
+
958
1212
  function openFile(relPath) {
959
1213
  let e = fileDocs.get(relPath)
960
1214
  if (e) return e
@@ -997,7 +1251,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
997
1251
  // The fork record arrived/changed from a peer: render it locally, or — if it was
998
1252
  // cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
999
1253
  conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
1000
- fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
1254
+ fprovider.on('sync', (s) => { if (s) { e.synced = true; refusedFiles.delete(relPath); reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
1255
+ // A refused upgrade lands here. The provider will keep retrying by itself, which is the
1256
+ // right behaviour and exactly why nothing surfaces it — so count it and let noteRefusals
1257
+ // decide whether this is a blip or a relay that will not take us.
1258
+ fprovider.on('connection-error', (ev) => {
1259
+ if (e.synced) return // already have the content; a later blip is the provider's business
1260
+ refusedFiles.set(relPath, (refusedFiles.get(relPath) || 0) + 1)
1261
+ // HIV-P13. THE STATUS CODE WAS THROWN AWAY HERE, and it is the only authoritative evidence
1262
+ // in the whole path. joinRoom in hive-mcp.js already captured it from this same event; this
1263
+ // handler took no argument at all, so noteRefusals had nothing but "N files are quiet" and
1264
+ // had to INFER a cause -- which is how it came to announce Q430 on 21 Sep while the relay
1265
+ // 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)
1267
+ if (code) refusedCodes.set(relPath, code)
1268
+ noteRefusals()
1269
+ })
1270
+ // Newly opened counts as used, and opening is the moment the working set can go over
1271
+ // its bound — so this is where the least-recently-used ones are let go.
1272
+ touched.set(relPath, Date.now())
1273
+ evictIfOverCap(relPath)
1001
1274
  return e
1002
1275
  }
1003
1276
  function closeFile(relPath) {
@@ -1010,6 +1283,60 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1010
1283
  }
1011
1284
  const fileText = (relPath) => { const e = fileDocs.get(relPath); return e ? e.text : null }
1012
1285
 
1286
+ // HIV-18: BOUND HOW MANY FILE SOCKETS THIS CLIENT HOLDS.
1287
+ //
1288
+ // Hivecode opens one WebSocket per FILE. A relay allows 512 connections in total by
1289
+ // default, and a normal project has more files than that, so a single client could
1290
+ // exhaust the whole relay by itself. Measured 18 Sep 2026: 889 files, 38,011 Q430
1291
+ // refusals in one evening, three agents locked out — and because a file-doc that never
1292
+ // syncs reads as EMPTY, 186 files per folder were written to 0 bytes.
1293
+ //
1294
+ // Holding every file open forever is what made that possible. A client now keeps a
1295
+ // BOUNDED working set: the most recently used MAX_OPEN files stay connected, the rest
1296
+ // are closed and reopened on demand. Closing a file doc costs nothing on disk — the
1297
+ // bytes stay where they are, the room keeps the content, and touching the file reopens
1298
+ // it and pulls the current copy.
1299
+ //
1300
+ // THE TRADE, stated plainly: a file outside the working set does not receive remote
1301
+ // edits until something touches it. That is a real semantic change, and it is strictly
1302
+ // better than the alternative it replaces, which was not receiving them either AND
1303
+ // believing the file was empty. The default is high enough that ordinary projects never
1304
+ // notice; size it as MAX_OPEN x agents < the relay's maxConnsTotal.
1305
+ //
1306
+ // NEVER EVICT, because these lose work rather than merely adding latency:
1307
+ // - a doc that has not synced (its content has not reached the relay yet)
1308
+ // - a file in an unresolved conflict (its fork record is live and local-only)
1309
+ // - a file anyone currently holds a claim on (they are mid-edit)
1310
+ // - anything used within the grace window
1311
+ const MAX_OPEN = Math.max(8, Number(process.env.HIVE_MAX_OPEN_FILES) || 128)
1312
+ // Configurable because the grace window is what makes eviction untestable otherwise: in a
1313
+ // short run every file was touched seconds ago, so nothing is ever eligible.
1314
+ const EVICT_GRACE_MS = Number(process.env.HIVE_EVICT_GRACE_MS) || 30000
1315
+ const touched = new Map() // relPath -> last use (ms)
1316
+ const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
1317
+ let evicted = 0
1318
+ function evictIfOverCap(keep) {
1319
+ if (fileDocs.size <= MAX_OPEN) return
1320
+ const now = Date.now()
1321
+ const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
1322
+ const cand = []
1323
+ for (const [r, e] of fileDocs) {
1324
+ if (r === keep || !e.synced) continue
1325
+ if (conflicted.has(r)) continue
1326
+ try { if (e.conflict && e.conflict.size) continue } catch { }
1327
+ if (isClaimed(r)) continue
1328
+ const t = touched.get(r) || 0
1329
+ if (now - t < EVICT_GRACE_MS) continue
1330
+ cand.push([r, t])
1331
+ }
1332
+ cand.sort((a, b) => a[1] - b[1]) // least recently used first
1333
+ let over = fileDocs.size - MAX_OPEN
1334
+ for (const [r] of cand) {
1335
+ if (over-- <= 0) break
1336
+ closeFile(r); touched.delete(r); evicted++
1337
+ }
1338
+ }
1339
+
1013
1340
  // My own path scope, read from my token (for THIS room). Used so I don't even
1014
1341
  // try to open files I'm not granted — the relay would reject them anyway, but
1015
1342
  // this avoids the churn and keeps out-of-scope files off my disk entirely.
@@ -1026,6 +1353,84 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1026
1353
  // or a full-write grant => everything I can open is writable (back-compat).
1027
1354
  const canWrite = (relPath) => !myScope || writeAllowed(myScope, relPath)
1028
1355
 
1356
+ // WOULD AN EDIT TO THIS PATH ACTUALLY REACH ANYONE? '' if yes, else the reason. (HIV-27)
1357
+ //
1358
+ // A claim is a path STRING in the shared `claims` map. Whether that path's BYTES sync is
1359
+ // decided somewhere else entirely, per client, by canOpen/isIgnored. Nothing compared the
1360
+ // two, so an agent could hold an exclusive claim on a file nobody else can see, do the work,
1361
+ // pass a gate that runs in its OWN folder where the file is real, and hand back something
1362
+ // that never existed for anyone else. Every signal green, the work genuinely done, and
1363
+ // invisible. Observed live: an agent edited scripts/check-deploy.mjs, claimed it, released it
1364
+ // through the gate, and the other two folders still held the old bytes.
1365
+ //
1366
+ // This is checked at CLAIM time on purpose. Release is too late -- by then the agent has
1367
+ // built the thing it intended to write, and all we could do is tell it the work was wasted.
1368
+ //
1369
+ // THE FALSE-POSITIVE DIRECTION IS WHAT MAKES THIS USABLE, and it is easy to get wrong.
1370
+ // A claim may name a logical region rather than a file ("auth-refactor", "the parser"), and
1371
+ // under an ALLOW-LIST .gitignore -- `/*` followed by negations, which is how a room bounds
1372
+ // itself against the one-socket-per-file limit -- isIgnored() returns true for every string
1373
+ // that is not an explicitly allowed path. A naive isIgnored() check would therefore refuse
1374
+ // almost every legitimate claim in exactly the rooms that need this guard most, and it would
1375
+ // be switched off within a day. So we only judge things we have positive reason to believe
1376
+ // are FILES: known to the room, present on disk, or carrying a directory separator.
1377
+ // HIV-P3, 20 Sep 2026. THE GENESIS CASE: the five tests above are all false for a root-level
1378
+ // file that DOES NOT EXIST YET, which is the most common reason anyone claims at all -- you
1379
+ // claim `config.env` precisely because you are about to create it. Found by DeepMind-Agent.
1380
+ // So HIV-27's guard had a hole in exactly the case it was written for: claim succeeds, write
1381
+ // succeeds, the local gate passes because in YOUR folder the file is real, and the bytes never
1382
+ // leave. Born invisible, every signal green.
1383
+ //
1384
+ // THE ONLY QUESTION IS: is this bare string a FILE-TO-BE or a LOGICAL LABEL? Nothing on disk
1385
+ // and nothing in the room can answer it, so this is a guess, and the direction of the guess is
1386
+ // what matters. Guessing "file" too eagerly refuses legitimate region claims -- and under an
1387
+ // allow-list .gitignore that means refusing ALMOST ALL of them, which gets the guard switched
1388
+ // off within a day and costs more than the hole. So every rule here must be one a LABEL would
1389
+ // essentially never satisfy.
1390
+ //
1391
+ // THE DIGIT RULE IS THE LOAD-BEARING ONE and it is not obvious. A trailing dot-segment
1392
+ // containing a LETTER is an extension: .js .env .secret. One that is ALL DIGITS is a version
1393
+ // number: v1.2, release-0.11.6, HIV-P3.5. Without that distinction every ticket id and release
1394
+ // label in this room's own vocabulary gets refused as a file.
1395
+ //
1396
+ // WHAT THIS DOES NOT CLOSE, stated rather than discovered later. Extensionless root files that
1397
+ // are neither conventional nor `*file` shaped -- a bare `config`, `staging`, `hosts` -- are
1398
+ // still read as labels and still go invisible. The hole narrows; it does not close. It cannot
1399
+ // close here, because at claim time the string is all we have. The rest belongs at WRITE time,
1400
+ // where a path that appears on disk inside the room folder is a fact and needs no heuristic.
1401
+ // That is HIV-P8, and it is filed rather than quietly assumed.
1402
+ const EXTENSIONLESS_ROOT_FILES = new Set([
1403
+ 'LICENSE', 'LICENCE', 'README', 'CHANGELOG', 'CHANGES', 'NOTICE', 'AUTHORS', 'CONTRIBUTORS',
1404
+ 'CODEOWNERS', 'COPYING', 'INSTALL', 'CONTRIBUTING', 'VERSION', 'MAINTAINERS', 'TODO',
1405
+ ])
1406
+ const looksLikeFilename = (p) => {
1407
+ if (/\s/.test(p)) return false // "the parser rewrite", "P3 lane"
1408
+ if (/\.[^.]*[A-Za-z][^.]*$/.test(p)) return true // .js .env .secret -- but NOT .2 .5 .6
1409
+ if (EXTENSIONLESS_ROOT_FILES.has(p.toUpperCase())) return true
1410
+ // Makefile, Dockerfile, Rakefile, Gemfile, Procfile, Vagrantfile, Justfile, Caddyfile.
1411
+ // Capital-initial and case-SENSITIVE on purpose: lowercase "profile" and "logfile" are
1412
+ // perfectly ordinary region names and must stay claimable.
1413
+ if (/^[A-Z][A-Za-z]+file$/.test(p)) return true
1414
+ return false
1415
+ }
1416
+
1417
+ const whyNotShared = (relPath) => {
1418
+ const p = String(relPath || '')
1419
+ if (!p) return ''
1420
+ let onDisk = false
1421
+ try { onDisk = fs.existsSync(path.join(ROOT, p)) } catch { onDisk = false }
1422
+ // SKIP counts as positive evidence: those names ARE files by definition, and a control
1423
+ // file sits in the room root with no separator in its name, so the heuristics below miss it.
1424
+ const isFile = manifest.has(p) || fileDocs.has(p) || SKIP.has(p) || onDisk || p.includes('/')
1425
+ || looksLikeFilename(p) // HIV-P3: the genesis case -- see above
1426
+ if (!isFile) return '' // a label, not a path -- not ours to judge
1427
+ if (!isSafeRelPath(p)) return `"${p}" is not a safe relative path, so it can never be synced`
1428
+ if (SKIP.has(p)) return `${p} is a Hivecode control file, not shared source`
1429
+ if (!canOpen(p)) return `your access token's scope does not cover ${p}, so its bytes stay in this folder`
1430
+ if (isIgnored(p)) return `${p} is ignored by a .gitignore here, so this client never opens a sync connection for it -- anything you write stays in this folder and no other agent will ever see it`
1431
+ return ''
1432
+ }
1433
+
1029
1434
  const known = new Set()
1030
1435
  const mtimes = new Map()
1031
1436
  // Paths that already existed on disk when this client STARTED. This is the
@@ -1033,6 +1438,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1033
1438
  // files" (adopt the room's copy) and "this file was created during the session"
1034
1439
  // (a creation race -- merge, never adopt). Populated once, on the first scan.
1035
1440
  const preexisting = new Set()
1441
+ // HIV-P14b. Manifest keys as of our first sync: what the ROOM held before we arrived.
1442
+ // `preexisting` records our DISK, which structurally cannot distinguish "nobody had this file"
1443
+ // from "the room had it all along and we never fetched it". null until the first sync, and null
1444
+ // must never be read as "the room was empty" -- that would classify every joiner's first
1445
+ // creation as a race, which is the bug this exists to fix.
1446
+ let roomAtJoin = null
1036
1447
  let firstScanDone = false
1037
1448
  const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
1038
1449
  // THE SAME THING, FROM THE PREVIOUS SESSION, and the reason it exists:
@@ -1056,15 +1467,28 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1056
1467
  // work (it would look like a deliberate deletion against an up-to-date base).
1057
1468
 
1058
1469
  const rel = (full) => path.relative(ROOT, full).split(path.sep).join('/')
1059
- function walk(d, acc = []) {
1470
+ // HIV-P8: `skipped` collects the files this walk DECLINES to sync, and that second list is the
1471
+ // whole ticket. Everything in this engine is derived from "the files we sync", so every natural
1472
+ // place to notice a born-invisible file is definitionally blind to it: walk() dropped ignored
1473
+ // paths, so scan() never saw them, so reconcile() was never called for them, so the guard at the
1474
+ // top of reconcile() was unreachable for exactly the class of file it names. Hooking there would
1475
+ // have passed a unit test that called reconcile() directly and done nothing in a real session.
1476
+ //
1477
+ // DIRECTORY PRUNING IS UNCHANGED, deliberately. Descending into ignored directories to find
1478
+ // ignored files would walk vendor and build trees on every tick. Measured on a real checkout with
1479
+ // pruning kept: 7934 synced, 256 ignored files visible, 280 directories descended, 191ms for the
1480
+ // whole traversal — and that traversal already happens. We are keeping an answer isIgnored()
1481
+ // already computed instead of discarding it. An ignored file inside an ignored directory stays
1482
+ // unseen, which is the correct trade at that price.
1483
+ function walk(d, acc = [], skipped = []) {
1060
1484
  let entries = []
1061
1485
  try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return acc }
1062
1486
  for (const e of entries) {
1063
1487
  if (IGNORE.has(e.name)) continue
1064
1488
  const full = path.join(d, e.name)
1065
1489
  const r = rel(full)
1066
- if (e.isDirectory()) { if (!isIgnored(r + '/')) walk(full, acc) } // prune ignored dirs
1067
- else if (e.isFile()) { if (!isIgnored(r)) acc.push(full) } // skip ignored/secret files
1490
+ if (e.isDirectory()) { if (!isIgnored(r + '/')) walk(full, acc, skipped) } // prune ignored dirs
1491
+ else if (e.isFile()) { if (isIgnored(r)) skipped.push(full); else acc.push(full) }
1068
1492
  }
1069
1493
  return acc
1070
1494
  }
@@ -1153,6 +1577,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1153
1577
  let fe = fileDocs.get(relPath)
1154
1578
  if (!fe) { if (disk === null) return; openFile(relPath); return }
1155
1579
  if (!fe.synced) return
1580
+ touchFile(relPath) // reconciling a file IS using it — keep it in the working set
1156
1581
  // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
1157
1582
  // this file, record any authored edit sitting on disk. The edit enters the provenance
1158
1583
  // DAG first; whatever happens to the disk afterwards can no longer lose it.
@@ -1203,9 +1628,29 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1203
1628
  if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
1204
1629
  }
1205
1630
  const docText = fe.text.toString()
1206
- if (docText === '' && !manifest.has(relPath)) {
1207
- // the relay has no copy of this path -> publish ours (a genuinely new file)
1631
+ // An empty shared doc is authoritative ONLY if we have history for this path.
1632
+ // A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
1633
+ // different connections — there is no atomicity across them. A client that joins a large
1634
+ // project and dies mid-join gets every manifest key up and only some file bodies, leaving
1635
+ // the room asserting that paths exist with nothing behind them. With no ancestor and real
1636
+ // bytes on disk, "" means the publisher never finished, not that the file is empty, and
1637
+ // adopting it deletes real work (measured: 186 files zeroed across three folders).
1638
+ // A genuine emptying of a file we HAVE synced still has an ancestor, so it falls through
1639
+ // to the merge below and still lands.
1640
+ const noHistory = !bases.has(relPath) && !priorShadow.has(relPath)
1641
+ // HIV-P14b tracing, env-gated. Which branch a blind recreate actually takes was guessed
1642
+ // wrong twice from reading the code; this answers it from the running product.
1643
+ if (process.env.HIVE_P14_DEBUG && new RegExp(process.env.HIVE_P14_DEBUG).test(relPath)) {
1644
+ log(`[P14] ${relPath} origin=${origin} synced=${fe.synced} docLen=${docText.length} ` +
1645
+ `diskLen=${disk === null ? 'null' : disk.length} hasBase=${bases.has(relPath)} ` +
1646
+ `priorShadow=${priorShadow.has(relPath)} inManifest=${manifest.has(relPath)} ` +
1647
+ `preexisting=${preexisting.has(relPath)}`)
1648
+ }
1649
+ if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
1650
+ // the relay has no usable copy of this path -> publish ours (a genuinely new file,
1651
+ // or one whose registration outlived the join that promised it)
1208
1652
  if (disk === null) return
1653
+ if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=genesis-publish ${relPath}`)
1209
1654
  fe.doc.transact(() => applyDiff(fe.text, disk))
1210
1655
  stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
1211
1656
  manifest.set(relPath, 1)
@@ -1253,6 +1698,72 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1253
1698
  // disjoint additions union (the normal case -- each author added their own
1254
1699
  // block), and a real overlap raises conflict markers for a human to settle
1255
1700
  // instead of the engine quietly picking a winner on arrival order.
1701
+ // HIV-P14b, OPEN AND DELIBERATELY NOT IMPLEMENTED HERE YET.
1702
+ //
1703
+ // There is a third case this dichotomy misses: the path appeared on OUR disk after we
1704
+ // started AND the room already had it before we joined. That is not a race -- the agent
1705
+ // could not see the file (bounded working set, nothing touched it), believed it did not
1706
+ // exist, and wrote one. Measured 21 Sep 2026: agent B recreated mod039.js and agent A's
1707
+ // copy ended up with two const declarations of the same name.
1708
+ //
1709
+ // `preexisting` records our DISK, so it structurally cannot tell those apart; the manifest
1710
+ // as of first sync can. A guard on that was written and REMOVED before shipping, because
1711
+ // the probe showed it never firing -- most likely the manifest is not yet populated at the
1712
+ // instant the base provider reports sync, so the snapshot came back empty. A refusal path
1713
+ // that silently never runs is worse than none: it reads as protection in review and does
1714
+ // nothing in production, which is the defect class this repo keeps hitting.
1715
+ //
1716
+ // What ships instead is the reporting half, which is verified: status() now exposes
1717
+ // filesHeld/roomFiles/filesMissing instead of a full house, and the join announces the gap
1718
+ // with the count and the remedy. An agent told "33 files exist here that you do not have,
1719
+ // reading the path fetches it" will not recreate them. That removes the cause; the guard
1720
+ // is the belt for when it happens anyway, and it lands when it is proven to fire.
1721
+ if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=no-base-block ${relPath} preexisting=${preexisting.has(relPath)}`)
1722
+ // HIV-P14b. A BLIND RECREATE IS NOT A RACE, and the empty-base merge below corrupts it.
1723
+ //
1724
+ // The dichotomy above has a third case: the path appeared on OUR disk after we started AND
1725
+ // the room already had it before we joined. The working set is bounded, nothing touched
1726
+ // that file, so the agent could not see it, concluded it did not exist, and wrote one.
1727
+ //
1728
+ // Measured 21 Sep 2026, traced through the running product. Agent B, holding 8 of a room's
1729
+ // 41 files, wrote mod039.js:
1730
+ // mod039.js origin=remote synced=true docLen=22 diskLen=39 hasBase=false
1731
+ // inManifest=true preexisting=false -> BRANCH=no-base-block
1732
+ // It lands exactly here, `preexisting` is false, and the empty-base merge unions two whole
1733
+ // files into agent A's copy:
1734
+ // export const v39 = 39
1735
+ // export const v39 = "FROM SCRATCH BY B"
1736
+ // Two const declarations of the same name. Invalid code, in someone else's folder, from B
1737
+ // doing the only thing available to it.
1738
+ //
1739
+ // The union is right for what the comment above describes -- two authors adding DIFFERENT
1740
+ // blocks to a file they both just created. It is wrong for two independent whole-file
1741
+ // creations of the same module, which is what a blind recreate always is.
1742
+ //
1743
+ // `roomAtJoin` is the discriminator `preexisting` cannot be: a path in it existed in the
1744
+ // ROOM before we did, however new it is on our disk. The `roomAtJoin &&` guard is
1745
+ // load-bearing -- before first sync it is null, and treating null as an empty room would
1746
+ // classify every joiner's first creation as a race.
1747
+ //
1748
+ // Adopt the room's copy, because it is the real file and the agent needs it to work from,
1749
+ // and snapshot the local draft so nothing the agent wrote is lost. Then say so loudly: the
1750
+ // entire failure was the agent not knowing the file was already there.
1751
+ if (roomAtJoin && roomAtJoin.has(relPath) && !preexisting.has(relPath)) {
1752
+ captureSnapshot(relPath, disk, name, {
1753
+ force: true, kind: 'manual',
1754
+ label: `local draft preserved — ${relPath} already existed in the room before this client joined`,
1755
+ })
1756
+ bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
1757
+ if (docText !== disk) writeToDisk(relPath, docText)
1758
+ try {
1759
+ say(`⚠ ${relPath} ALREADY EXISTED IN THE ROOM before I joined — I had not fetched it, so it was ` +
1760
+ `written here from scratch. The room's copy is now on disk and my draft was snapshotted. ` +
1761
+ `This folder holds a bounded working set: a file you cannot see may still exist, so read or ` +
1762
+ `claim the path before creating it. hive_status reports filesHeld of roomFiles.`)
1763
+ } catch { }
1764
+ if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=blind-recreate-refused ${relPath}`)
1765
+ return
1766
+ }
1256
1767
  if (!preexisting.has(relPath)) {
1257
1768
  // Terminate both sides before merging. With an empty base there is no shared
1258
1769
  // trailing context, so a side whose last line lacks a newline gets its final
@@ -1260,6 +1771,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1260
1771
  // which is worse than failing -- it reads as corruption to whoever opens it.
1261
1772
  const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
1262
1773
  const r0 = icrMerge3('', nl(disk), nl(docText), relPath)
1774
+ // HIV-P17: SILENT COLLISION DETECTION on genesis-publish creation race.
1775
+ // The normal merge path (below, ~line 1880) runs fusionReason() to catch merges
1776
+ // that broke valid code or duplicated structure. This creation-race path skipped
1777
+ // that check entirely — a dead-heat race could silently fuse two whole-file
1778
+ // rewrites of the same module and nobody was told. Now we run the same check:
1779
+ // if both inputs parsed but the merged result does not, or if the merge duplicated
1780
+ // structure, refuse it — keep both sides intact and announce loudly.
1781
+ if (!r0.conflict) {
1782
+ const why = fusionReason(r0.text, nl(disk), nl(docText), supports(relPath) ? languageFor(relPath) : null)
1783
+ if (why) {
1784
+ conflicted.add(relPath)
1785
+ // Do NOT write the fused result to doc or disk. The doc already holds docText
1786
+ // (the room's copy) and disk holds the local draft — both are intact. Setting
1787
+ // bases to docText means the next normal edit will merge against the room's copy,
1788
+ // which is the same recovery path the normal-merge refusal uses.
1789
+ bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
1790
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
1791
+ if (icrWarned.get(relPath) !== why) {
1792
+ icrWarned.set(relPath, why)
1793
+ log(`[${name}] !! HIV-P17 REFUSED a fused creation-race merge of ${relPath}: ${why}`)
1794
+ try {
1795
+ say(`[!] @all ${relPath}: a creation-race merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
1796
+ } catch { /* the log still carries it */ }
1797
+ }
1798
+ return
1799
+ }
1800
+ }
1263
1801
  if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
1264
1802
  if (r0.text !== disk) writeToDisk(relPath, r0.text)
1265
1803
  if (r0.conflict) conflicted.add(relPath)
@@ -1294,14 +1832,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1294
1832
  try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
1295
1833
  return
1296
1834
  }
1297
- // Genuinely no ancestor (a clone-then-join). Adopt the room's copy, keep ours as a
1298
- // restore point — and SAY SO. This branch used to be the only silent one on this
1299
- // path, which is why the data loss went unnoticed rather than merely being rare.
1300
- captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
1301
- writeToDisk(relPath, docText)
1302
- bases.set(relPath, docText); forkBases.set(relPath, docText)
1303
- if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
1304
- try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
1835
+ // Genuinely no ancestor (a clone-then-join, or joining with an existing checkout).
1836
+ // HIV-P9: Do NOT adopt the room's copy blindly. Check provenance!
1837
+ // Has the room ever seen what is currently on disk?
1838
+ const receipts = fe && fe.ledger ? fe.ledger.toArray() : []
1839
+ const st = accountFor({ rel: relPath, branchText: disk, roomText: docText, receipts })
1840
+
1841
+ if (st.state === DESCENDS || st.state === IDENTICAL) {
1842
+ // Safe: The room has passed through our disk content, so the room's copy is a true descendant.
1843
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
1844
+ writeToDisk(relPath, docText)
1845
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
1846
+ if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
1847
+ try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
1848
+ return
1849
+ }
1850
+
1851
+ // DIVERGED or UNPROVEN: The room has NEVER seen local disk's content, or has no proof.
1852
+ // Overwriting disk with docText would cause silent data loss (e.g. wiping out local fixes).
1853
+ // Merging from an empty base would concatenate both copies, creating invalid syntax.
1854
+ // Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
1855
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
1856
+ bases.set(relPath, disk); forkBases.set(relPath, disk)
1857
+ try {
1858
+ say(`⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted. Local disk was preserved. Resolve deliberately.`)
1859
+ } catch { }
1305
1860
  return
1306
1861
  }
1307
1862
  const base = bases.has(relPath) ? bases.get(relPath) : disk
@@ -1337,6 +1892,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1337
1892
  res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
1338
1893
  }
1339
1894
 
1895
+ // FUSION GUARD (HIV-26). A merge must never produce something NEITHER author wrote.
1896
+ //
1897
+ // On 18 Sep 2026 three peers wrote the same file within about a second of each other and
1898
+ // the character-CRDT UNIONED the versions instead of converging: sync.js went 144,144 ->
1899
+ // 291,750 bytes, `const MAX_OPEN` appeared three times, and the splice landed mid-token
1900
+ // (`evicted// Newly opened...`). There were NO conflict markers, every peer converged on
1901
+ // the corruption — so agreement proved nothing — and the only thing that caught it was a
1902
+ // parse failure at the handoff gate, by which point it had already reached every folder.
1903
+ //
1904
+ // Two cheap signals, each catching what the other misses:
1905
+ // - the merge BROKE a file both sides could parse. Definitionally wrong: whatever that
1906
+ // output is, neither author wrote it.
1907
+ // - the result is about as long as both sides put together. That is concatenation in a
1908
+ // merge's clothing, and it catches a fusion that happens to still parse.
1909
+ //
1910
+ // We land NOTHING. Not a conflict block either: this engine deliberately keeps markers
1911
+ // out of the shared text, because writing them into the very layer that fuses is what
1912
+ // produced marker-fusion before. So the doc keeps its version, disk keeps its version,
1913
+ // both are still intact and recoverable, and the room is TOLD. A file left visibly
1914
+ // divergent is a bad day; a file silently fused is an unrecoverable one.
1915
+ if (res && !res.conflict) {
1916
+ const why = fusionReason(res.text, disk, docText, supports(relPath) ? languageFor(relPath) : null)
1917
+ if (why) {
1918
+ conflicted.add(relPath)
1919
+ if (icrWarned.get(relPath) !== why) {
1920
+ icrWarned.set(relPath, why)
1921
+ log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
1922
+ try {
1923
+ say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
1924
+ } catch { /* the log still carries it */ }
1925
+ }
1926
+ return
1927
+ }
1928
+ }
1929
+
1340
1930
  // Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
1341
1931
  // captured only on local origin so each change is recorded once, by its author.
1342
1932
  if (origin === 'local' && res.text !== docText) {
@@ -1682,7 +2272,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1682
2272
  let myEditing = null, myLastEdit = null
1683
2273
  let editingClearTimer = null
1684
2274
  const warnedAt = new Map() // file -> last time we warned about co-editing it
1685
- const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION })
2275
+ const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, passive: !!passive })
1686
2276
  function markEditing(relPath) {
1687
2277
  myEditing = { file: relPath, at: Date.now() }
1688
2278
  myLastEdit = myEditing
@@ -1730,6 +2320,44 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1730
2320
  }
1731
2321
  writeToDisk(MEMBERS_FILE, out.join('\n') + '\n')
1732
2322
  }
2323
+ /** The claim map as a file. Same contract as renderMembers: generated locally, never synced.
2324
+ *
2325
+ * EXPIRY IS SHOWN, NOT HIDDEN. A claim whose TTL has run out is not a lock any more — the
2326
+ * region is free — so rendering it as "held" would make agents queue behind a crashed peer,
2327
+ * which is the deadlock TTLs exist to prevent. Expired rows stay visible but labelled, because
2328
+ * "AgentX died holding this" is information, and silently dropping the row loses it. */
2329
+ function renderClaims() {
2330
+ const now = Date.now()
2331
+ const rows = []
2332
+ claims.forEach((c, region) => {
2333
+ if (!c || !c.by) return
2334
+ const ttl = Number(c.ttl) || 0
2335
+ const age = now - (Number(c.at) || 0)
2336
+ const left = ttl - age
2337
+ rows.push({ region, by: c.by, intent: c.intent || 'edit', age, left, dead: left <= 0 })
2338
+ })
2339
+ rows.sort((a, b) => a.region.localeCompare(b.region))
2340
+
2341
+ const secs = (ms) => `${Math.max(0, Math.round(ms / 1000))}s`
2342
+ const live = rows.filter((r) => !r.dead)
2343
+ const out = [
2344
+ '# Hive Claims — who holds which file right now (live).',
2345
+ '# SENSE this before you edit. A region held by someone else is not yours to write:',
2346
+ '# flow to open work instead of waiting. Claims expire on their own, so nothing deadlocks.',
2347
+ '',
2348
+ `held: ${live.length}`,
2349
+ '',
2350
+ ]
2351
+ for (const r of rows) {
2352
+ out.push(r.dead
2353
+ ? `- ${r.region} — EXPIRED (was ${r.by}, ${r.intent}, ${secs(r.age)} ago) — free to take`
2354
+ : `- ${r.region} — ${r.by} (${r.intent}) — held ${secs(r.age)}, ${secs(r.left)} left`)
2355
+ }
2356
+ if (!rows.length) out.push('- nothing claimed; every region is open')
2357
+ writeToDisk(CLAIMS_FILE, out.join('\n') + '\n')
2358
+ }
2359
+ if (syncFiles) claims.observe(() => renderClaims())
2360
+
1733
2361
  // Warn (once per peer) when someone joins on an incompatible protocol version —
1734
2362
  // otherwise they'd silently fail to sync and look like a "broken room".
1735
2363
  const versionWarned = new Set()
@@ -1744,18 +2372,108 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1744
2372
  try { say(`⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`) } catch { }
1745
2373
  }
1746
2374
  }
1747
- if (syncFiles) provider.awareness.on('change', () => { renderMembers(); checkVersions() })
2375
+ if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
2376
+
2377
+ // HIV-P8: A FILE THAT WILL NEVER LEAVE THIS FOLDER, SAID OUT LOUD.
2378
+ //
2379
+ // HIV-P3 closed the genesis-claim hole at CLAIM time by classifying the claim string, and it
2380
+ // could only ever be a classifier: at claim time a bare string is all there is, and `config`,
2381
+ // `hosts` and `staging` are indistinguishable from ticket labels. At WRITE time there is no
2382
+ // ambiguity left — the file is on disk and isIgnored() is exact. So this is the backstop for
2383
+ // everything the classifier could not decide.
2384
+ //
2385
+ // IT CANNOT REFUSE. The bytes are already written by the time anyone knows, so this is
2386
+ // structurally a warning — and a warning is exactly what every born-invisible incident has
2387
+ // already scrolled past. That is why the claimed case opens an OBLIGATION instead of logging:
2388
+ // an obligation rides hive_wait, backs off, escalates to STALE and shows on the board. It is
2389
+ // un-ignorable by construction and costs nothing while nobody is idle.
2390
+ //
2391
+ // THE TWO GATES ARE THE WHOLE DESIGN, and the numbers are why. On a real checkout there are 256
2392
+ // ignored files. Warning about all of them would open 256 debts on join, each closable only by
2393
+ // its author, and the ledger would be unusable inside one join — the same flooding we spent this
2394
+ // morning clearing forty entries of. So:
2395
+ // CLAIMED the author declared they were working on this path. That is their own stated
2396
+ // intent, already in the CRDT — not a guess about the filename.
2397
+ // NOT PREEXISTING it appeared during this session, rather than having sat there for a week.
2398
+ // Neither alone is sufficient: without the claim gate, 256; without the other, every long-lived
2399
+ // ignored file re-fires on every join.
2400
+ //
2401
+ // THE UNCLAIMED CASE GETS ONE LINE AND NO LEDGER ENTRY. The room rule is CLAIM BEFORE YOU WRITE,
2402
+ // so an unclaimed write is outside the protocol and does not get to consume the ledger. I am not
2403
+ // confident this tier earns its place — a chat line is precisely the signal these incidents
2404
+ // scroll past — but deleting the case entirely is worse than announcing it cheaply.
2405
+ const ENGINE_ID = 'hivecode'
2406
+ const invisibleWarned = new Map() // relPath -> obligation id, or null for the ambient case
2407
+
2408
+ function noticeBornInvisible(ignoredRel) {
2409
+ // DISCHARGE FIRST. A file that has gone away takes its debt with it: the question "this will
2410
+ // never sync" stops being a question when there is nothing left to sync. Left open, the ledger
2411
+ // fills with debts about paths that no longer exist, which is the noise the three-state
2412
+ // lifecycle exists to prevent. Its expiry cannot help here — that only covers names never seen
2413
+ // in the room, and this path was seen, on disk, by us.
2414
+ for (const [r, id] of [...invisibleWarned]) {
2415
+ if (ignoredRel.has(r)) continue
2416
+ if (id) { try { OB.discharge(doc.getMap('obligations'), { id, by: ENGINE_ID }) } catch { } }
2417
+ invisibleWarned.delete(r)
2418
+ }
2419
+ for (const r of ignoredRel) {
2420
+ if (SKIP.has(r)) continue // control files are ours and are supposed to be invisible
2421
+ if (preexisting.has(r)) continue // it was already here; nobody did this during the session
2422
+ if (invisibleWarned.has(r)) continue // once per path, not once per scan tick
2423
+ const holder = coordinator.sense(r)
2424
+ if (holder && holder.by) {
2425
+ let id = null
2426
+ try {
2427
+ const rec = OB.open(doc.getMap('obligations'), {
2428
+ from: ENGINE_ID,
2429
+ to: holder.by,
2430
+ text: `${r} is on disk and claimed by you, but it is ignored by a .gitignore here — ` +
2431
+ `no sync connection is opened for it, so nothing written to it will ever reach the room. ` +
2432
+ `Add it to the allow-list, or confirm the work is deliberately local.`,
2433
+ absent: false,
2434
+ neverSeen: false,
2435
+ })
2436
+ id = rec && rec.id
2437
+ } catch { }
2438
+ invisibleWarned.set(r, id)
2439
+ try {
2440
+ say(`⛔ ${r} is claimed and on disk, but it is ignored by a .gitignore here — its bytes will never leave this folder.`)
2441
+ } catch { }
2442
+ } else {
2443
+ invisibleWarned.set(r, null)
2444
+ try {
2445
+ say(`· ${r} was created here but is ignored by a .gitignore — it stays local, and no other agent will see it.`)
2446
+ } catch { }
2447
+ }
2448
+ }
2449
+ }
1748
2450
 
1749
2451
  function scan() {
1750
- const diskFulls = walk(ROOT)
2452
+ const skippedFulls = []
2453
+ const diskFulls = walk(ROOT, [], skippedFulls)
1751
2454
  const diskRel = new Set(diskFulls.map(rel))
2455
+ const ignoredRel = new Set(skippedFulls.map(rel))
1752
2456
  // Snapshot the starting file set BEFORE the first reconcile pass touches anything.
1753
2457
  // Everything here pre-dates our participation; anything that shows up later was
1754
2458
  // authored while we were connected. See `preexisting`.
1755
2459
  // The ancestor must be loaded BEFORE any path can reach reconcile(), and this is the
1756
2460
  // one place guaranteed to run first exactly once. Loading it later would mean the first
1757
2461
  // scan's reconciles see no ancestor — precisely the bug this is here to close.
1758
- if (!firstScanDone) { loadPriorShadow(); for (const r of diskRel) preexisting.add(r); firstScanDone = true }
2462
+ //
2463
+ // HIV-P8: THE UNION IS NOT A DETAIL. `preexisting` used to be seeded from diskRel alone, and
2464
+ // diskRel comes from walk(), which dropped ignored files — so `preexisting.has(r)` was
2465
+ // permanently FALSE for every ignored path, whether it was created a second ago or had been
2466
+ // sitting there for a week. Any check phrased as "ignored AND not preexisting" therefore
2467
+ // reduced to "ignored", and a client joining a real checkout would have opened an obligation
2468
+ // for each of its 256 ignored files, about files nobody touched. Seeding the union is what
2469
+ // makes "not preexisting" mean what it says: authored during this session.
2470
+ if (!firstScanDone) {
2471
+ loadPriorShadow()
2472
+ for (const r of diskRel) preexisting.add(r)
2473
+ for (const r of ignoredRel) preexisting.add(r)
2474
+ firstScanDone = true
2475
+ }
2476
+ noticeBornInvisible(ignoredRel)
1759
2477
  for (const full of diskFulls) {
1760
2478
  const r = rel(full)
1761
2479
  if (SKIP.has(r)) continue
@@ -1783,8 +2501,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1783
2501
  try { fs.rmSync(path.join(ROOT, key)) } catch { }
1784
2502
  known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key)
1785
2503
  log(`[${name}] <- deleted ${key}`)
1786
- } else if (canOpen(key)) {
1787
- openFile(key)
2504
+ } else if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
2505
+ // Same rule as the initial sweep: a path this client ignores must not cost it a
2506
+ // socket just because somebody else registered it.
2507
+ //
2508
+ // AND the same BOUND. This observer is how a joining client learns the room's whole
2509
+ // manifest — every existing path arrives here as a remote change — so capping only
2510
+ // the sweep achieved nothing: the first measurement of this cap showed a client with
2511
+ // cap 8 holding 30 file docs, every one of them opened right here. A path we decline
2512
+ // to open now is not lost; it opens when something touches it.
2513
+ if (fileDocs.size < MAX_OPEN) openFile(key)
1788
2514
  }
1789
2515
  })
1790
2516
  })
@@ -1807,15 +2533,74 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1807
2533
 
1808
2534
  provider.on('sync', (s) => {
1809
2535
  if (!s || !syncFiles) return
2536
+ // HIV-P14b. Snapshot what the room already had, once. Measured: the manifest IS populated at
2537
+ // this point -- a fresh joiner's observer fires with 13 of 13 entries before the sync event,
2538
+ // so reading it here is sound. (That was checked because the first version of this guard was
2539
+ // wrongly assumed to be failing for this reason.)
2540
+ if (!roomAtJoin) {
2541
+ roomAtJoin = new Set()
2542
+ try { for (const k of manifest.keys()) roomAtJoin.add(k) } catch { }
2543
+ }
1810
2544
  writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
1811
2545
  writeToDisk(AGENTS_FILE, AGENT_MANUAL_TEXT) // so an agent opening this folder learns how to join + behave
1812
- for (const key of manifest.keys()) if (canOpen(key)) openFile(key) // connect to every file I'm granted; each file-room's sync pulls its content
2546
+ // Connect to every file I'm granted AND have not excluded; each file-room's sync pulls
2547
+ // its content. `canOpen` is the TOKEN's scope -- what the relay would let me see. It is
2548
+ // not the same question as "do I want this file", and using it alone meant a participant
2549
+ // could not bound its own footprint: on 18 Sep 2026 two agents kept opening demo/** file
2550
+ // rooms after their .gitignore had been narrowed to 35 paths, and between them consumed
2551
+ // the relay's entire 512-connection budget. Since one socket is opened PER FILE, an
2552
+ // ignore rule that does not stop the connection is not a containment tool at all.
2553
+ // Same predicate reconcile() uses, so a file is never open but unreconcilable.
2554
+ // BOUND THE INITIAL SWEEP, not just the steady state. This burst is precisely when the
2555
+ // budget blows: a client with 889 files used to try to open 889 sockets at once against
2556
+ // a 512-connection relay. LRU eviction cannot help here — during a burst every file was
2557
+ // touched seconds ago, so nothing is eligible to evict. The only thing that works is to
2558
+ // not open them all.
2559
+ //
2560
+ // Files that exist on THIS client's disk come first: they are the ones that need
2561
+ // 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.`)
2571
+ }
1813
2572
  if (board.size) renderBoard()
1814
2573
  if (chat.length) renderChat()
1815
2574
  if (tasks.size) renderTasks()
1816
2575
  renderMembers()
2576
+ renderClaims() // unconditional: "nothing is claimed" is the answer an arriving agent needs most
1817
2577
  scan()
1818
2578
  log(`[${name}] folder sync active on ${ROOT} (room "${room}") as ${kind}. ${manifest.size} files.`)
2579
+ // HIV-P14. SAY THE GAP OUT LOUD, ONCE, AT JOIN.
2580
+ //
2581
+ // The working set is bounded on purpose, and the documented trade is that "a file outside it
2582
+ // does not arrive until something touches it". For a human reading a folder that is fine. For
2583
+ // an AI agent it is not, because an agent decides what exists by listing its directory: a
2584
+ // file it cannot see is a file it believes does not exist, and it writes one. Measured 21 Sep
2585
+ // 2026, a 40-file room and MAX_OPEN=8: the joiner held 7 files, reported "filesSynced 8/8",
2586
+ // said nothing, and its agent recreated a file the room already had.
2587
+ //
2588
+ // Nothing here changes the cap. It tells the agent the number it needs in order to ASK,
2589
+ // which is the one thing that makes "until something touches it" true.
2590
+ setTimeout(() => {
2591
+ try {
2592
+ const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
2593
+ const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
2594
+ const short = want.length - have.length
2595
+ if (short <= 0) return
2596
+ log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
2597
+ `${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. ` +
2600
+ `Do not treat it as absent and do not create it: reading or claiming the path fetches it. ` +
2601
+ `hive_status reports filesHeld of roomFiles, and hive_read_board lists what the room holds.`)
2602
+ } catch { }
2603
+ }, 4000).unref?.()
1819
2604
  if (!scanTimer) {
1820
2605
  startWatch()
1821
2606
  scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
@@ -1919,9 +2704,91 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1919
2704
  isPaused, // is a participant currently paused?
1920
2705
  controls, // the live control map
1921
2706
  // --- hive coordination: claim a region BEFORE editing (collision prevention) ---
1922
- claim: (region, intent) => { const r = String(region), i = String(intent || 'edit'); myIntents.set(r, i); return coordinator.acquire(r, i) }, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
1923
- release: (region) => { const r = String(region); myIntents.delete(r); return coordinator.release(r) },
2707
+ // HIV-P18: a claim is a promise about BYTES, so granting one over bytes the claimer cannot
2708
+ // see is the same false success we shut in the outbound direction (HIV-27: a claim whose
2709
+ // writes could never leave). The claim itself is unchanged -- callers and every existing
2710
+ // suite still get the same boolean -- but it now also starts the fetch the README has been
2711
+ // promising, so an agent that claims a path it does not hold actually receives it instead of
2712
+ // finding an empty directory and creating the file from scratch.
2713
+ claim: (region, intent) => {
2714
+ const r = String(region), i = String(intent || 'edit')
2715
+ myIntents.set(r, i)
2716
+ const held = coordinator.acquire(r, i)
2717
+ // Fire-and-forget on purpose: claim's contract is synchronous and changing that would
2718
+ // break every caller. Use fetch(path) directly when you need to WAIT for the bytes.
2719
+ if (held) { try { fetchFile(r).catch(() => {}) } catch { } }
2720
+ return held
2721
+ }, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
2722
+ // HIV-P18: bring a file the room has but this folder does not. Resolves when the BYTES have
2723
+ // landed, and distinguishes "outside the working set" (fetchable) from "not in the room at
2724
+ // all" (nothing will ever arrive) -- two states that look identical from an empty directory
2725
+ // and have opposite remedies.
2726
+ fetch: fetchFile,
2727
+ // RELEASE IS A HANDOFF, AND A HANDOFF CAN BE REFUSED.
2728
+ //
2729
+ // Until the room sets `gate:cmd`, this behaves exactly as it always did and returns
2730
+ // {ok:true} — no configured gate means no new failure mode for existing rooms.
2731
+ //
2732
+ // With a gate set, the check runs in THIS agent's folder and a non-zero exit keeps the claim.
2733
+ // That is the whole idea: the file stays visibly yours, so the next agent cannot quietly
2734
+ // inherit something that looked finished. It does NOT unsend the bytes — the CRDT syncs
2735
+ // continuously and they are already everywhere — it refuses the "I am done, it is yours".
2736
+ //
2737
+ // `force` exists because abandoning work is legitimate, and it is never silent: it releases,
2738
+ // records that it was forced, and says so in the room.
2739
+ release: (region, opts = {}) => {
2740
+ const r = String(region)
2741
+ const g = gateConfig((k) => swarmState.get(k))
2742
+ const letGo = () => { myIntents.delete(r); coordinator.release(r) }
2743
+ if (!g) { letGo(); return { ok: true, gated: false } }
2744
+
2745
+ if (opts.force) {
2746
+ const rec = verdictRecord({ region: r, by: name, cmd: g.cmd, result: null, forced: true, why: opts.why })
2747
+ gateResults.set(`${r}|${name}`, rec)
2748
+ letGo()
2749
+ try { say(forcedMessage({ region: r, by: name, why: opts.why })) } catch { /* chat is not the point */ }
2750
+ return { ok: true, gated: true, forced: true }
2751
+ }
2752
+
2753
+ const result = runGate({ cmd: g.cmd, cwd: ROOT, timeoutMs: g.timeoutMs })
2754
+ gateResults.set(`${r}|${name}`, verdictRecord({ region: r, by: name, cmd: g.cmd, result }))
2755
+ if (!result.ok) {
2756
+ // The claim is NOT dropped. Saying so in the room is what makes the refusal visible to
2757
+ // everyone rather than a private error the holder can ignore.
2758
+ try { say(`@all ${name} could not hand back ${r}: the gate failed (exit ${result.code}). Still held.`) } catch { /* ignore */ }
2759
+ return { ok: false, gated: true, code: result.timedOut ? 'Q491' : 'Q490', result, message: refusalMessage({ region: r, cmd: g.cmd, result }) }
2760
+ }
2761
+ letGo()
2762
+ return { ok: true, gated: true, result }
2763
+ },
2764
+ /** The verdicts recorded for a path, by peer. Read this before building on someone's file. */
2765
+ gateVerdicts: (region) => {
2766
+ const out = {}
2767
+ const pre = String(region) + '|'
2768
+ gateResults.forEach((v, k) => { if (k.startsWith(pre)) out[k.slice(pre.length)] = v })
2769
+ return out
2770
+ },
2771
+ /** The room's gate, or null. Exposed so a client can say what it is about to be judged by. */
2772
+ gate: () => gateConfig((k) => swarmState.get(k)),
2773
+ // THE LOCK YOU MAY WRITE BEHIND. `claim()` answers from this replica alone, so under real
2774
+ // concurrency it says yes to everyone (see hive-coord.js). This waits for the claim to
2775
+ // round-trip through the relay -- the only place the room is actually serialised -- and then
2776
+ // asks who the converged map says holds it. Exactly one caller gets true.
2777
+ //
2778
+ // The loser has nothing to release: the winner's entry replaced its own under last-writer-
2779
+ // wins on the same key, so conceding is just returning false.
2780
+ claimSettled: async (region, intent, waitMs = 900) => {
2781
+ const r = String(region), i = String(intent || 'edit')
2782
+ myIntents.set(r, i)
2783
+ if (!coordinator.acquire(r, i)) return false // someone already visibly holds it
2784
+ await new Promise((res) => setTimeout(res, Math.max(200, waitMs)))
2785
+ const holder = coordinator.sense(r)
2786
+ if (holder && holder.by === name) return true
2787
+ myIntents.delete(r)
2788
+ return false
2789
+ },
1924
2790
  senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
2791
+ whyNotShared, // '' if edits to this path reach the room, else why they would not (HIV-27)
1925
2792
  claimsBoard: () => coordinator.board(), // everything currently claimed
1926
2793
  // --- intent-aware fork resolution: any agent may judge; the medium validates ---
1927
2794
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
@@ -1958,7 +2825,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1958
2825
  const u = s.user
1959
2826
  if (!u || !u.name) continue
1960
2827
  const prev = seen.get(u.name) || {}
1961
- seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner })
2828
+ seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive) })
1962
2829
  }
1963
2830
  return [...seen.values()]
1964
2831
  },
@@ -1973,11 +2840,54 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1973
2840
  status: () => {
1974
2841
  let synced = 0
1975
2842
  for (const e of fileDocs.values()) if (e.synced) synced++
2843
+ // `notSyncing` is the number the caller actually needs when something is wrong: files
2844
+ // whose own socket the relay refused. They are not slow, they are not arriving, and
2845
+ // until they do they read as empty. filesSynced alone shows a shortfall but not a cause.
2846
+ const notSyncing = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
2847
+ // HIV-P14. THE FULL-HOUSE LIE.
2848
+ //
2849
+ // `files` and `filesSynced` both counted OPEN docs, so a client holding eight of the
2850
+ // room's forty files reported "filesSynced 8/8" -- a complete set. Measured 21 Sep 2026:
2851
+ // a second agent joined a 40-file room, received 7, stayed at 7 for a full minute, and
2852
+ // reported 8/8. Its manifest listed all forty the whole time.
2853
+ //
2854
+ // The bounded working set is deliberate and correct -- one socket per file against a 512
2855
+ // ceiling is the wall, and tests/open-file-cap-test.mjs documents the trade rather than
2856
+ // hiding it: "a file outside the working set does not arrive until something touches it."
2857
+ // What that header does not cover is that FOR AN AGENT, NOTHING EVER TOUCHES IT. An agent
2858
+ // decides what exists by reading its folder; a file it cannot see is a file it believes
2859
+ // does not exist, so it writes one from scratch. Measured consequence, same probe: agent B
2860
+ // created mod039.js, and agent A's copy ended up holding two const declarations of the
2861
+ // same name -- invalid code, in someone else's folder, from B doing the only reasonable
2862
+ // thing available to it.
2863
+ //
2864
+ // So the trade stays and the reporting changes. `files` keeps meaning open docs, because
2865
+ // tests/open-file-cap-test.mjs:71 pins it as the proof the cap is respected. What is added
2866
+ // is the pair that was missing: what the ROOM has, and what this client actually HOLDS.
2867
+ const roomFiles = (() => { try { return [...manifest.keys()].filter((r) => canOpen(r)).length } catch { return 0 } })()
2868
+ const held = (() => {
2869
+ let n = 0
2870
+ try { for (const r of manifest.keys()) { if (canOpen(r) && fs.existsSync(path.join(ROOT, r))) n++ } } catch { }
2871
+ return n
2872
+ })()
1976
2873
  return {
1977
2874
  connected: !!provider.wsconnected,
1978
2875
  connecting: !!provider.wsconnecting,
1979
2876
  files: fileDocs.size,
1980
2877
  filesSynced: synced,
2878
+ // The honest pair. `filesHeld` of `roomFiles` is the number a joiner needs, and the one
2879
+ // that says 7 of 40 where filesSynced said 8 of 8.
2880
+ roomFiles,
2881
+ filesHeld: held,
2882
+ filesMissing: Math.max(0, roomFiles - held),
2883
+ notSyncing: notSyncing.length,
2884
+ notSyncingFiles: notSyncing.slice(0, 20),
2885
+ // The working set and its bound. `openCap` x agents must stay under the relay's
2886
+ // maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
2887
+ // how this room actually works — both are things an operator can only act on if
2888
+ // they can see them.
2889
+ openCap: MAX_OPEN,
2890
+ evicted,
1981
2891
  }
1982
2892
  },
1983
2893
  stop: () => {