hivecode-mcp 0.12.2 → 0.13.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.
Files changed (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/sync.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import fs from 'fs'
19
19
  import os from 'os'
20
20
  import path from 'path'
21
+ import crypto from 'crypto'
21
22
  import * as Y from 'yjs'
22
23
  import { WebsocketProvider } from 'y-websocket'
23
24
  import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
@@ -35,7 +36,13 @@ import { genIdentity, authorChange, verifyReceipt, contentHash } from './substra
35
36
  // ReferenceError and killed the sync process outright. Nothing caught it -- the client
36
37
  // simply died mid-session, which reads to a user as "sync stopped working". Rewriting a
37
38
  // whole file is the most common thing an agent does, so this fired constantly.
38
- import grmLink from './grm-link.cjs' // hands this room's history to GRM, so the ROOM has a memory
39
+ import { createRequire } from 'module'
40
+ import { VERSION, VERSION_LABEL } from './hive-version.js' // which build this client is, carried in presence
41
+ // ROOM MEMORY LINK (29 Sep 2026). An optional local add-on that is not in the published package. A
42
+ // checkout that has the file links the room; an npm install simply has no room-memory link, and
43
+ // nothing else changes. Loaded, not imported, because a static import of an unshipped file fails at boot.
44
+ let grmLink = null
45
+ try { grmLink = createRequire(import.meta.url)('./grm-link.cjs') } catch { /* not shipped: no room memory link */ }
39
46
  import { structuralMerge, supports, languageFor } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
40
47
  import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from './hive-pr-staleness.js' // room-versus-branch provenance staleness checks (HIV-P9)
41
48
  // HIV-P8. The map key MUST be the string 'obligations' — hive-mcp.js:656 reads that exact key, and
@@ -43,7 +50,9 @@ import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from '.
43
50
  // the defect this ticket is about, one level down.
44
51
  import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
45
52
  import { checkRelay, currentRelayFor } from './hive-privacy.js' // HIV-P20; which relay is this, and did anybody choose it
53
+ import { newSecrets, describeSecret } from './hive-secrets.js' // a credential inside a file is held here, not sent to the room
46
54
  import * as OB from './hive-obligations.js'
55
+ import { formatTime } from './hive-time.js'
47
56
 
48
57
  // Wire-format/protocol version. Bump when a change makes clients incompatible
49
58
  // (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
@@ -52,14 +61,29 @@ const PROTOCOL_VERSION = 2
52
61
 
53
62
  // Never sync these, even if not in .gitignore — secrets and obvious junk. This
54
63
  // prevents pushing a teammate's .env / private keys / build output to the room.
55
- const ALWAYS_IGNORE = [
64
+ // Exported so hive-branch.js can tell, before seeding a room from a git tree, which tracked
65
+ // files the room will never carry.
66
+ export const ALWAYS_IGNORE = [
56
67
  '.git/', 'node_modules/',
68
+ // `.git` as a FILE too. '.git/' matches only a directory, and in a git worktree (or a submodule,
69
+ // or a branch-room folder, which holds a `.git` file that makes git refuse to run there) `.git`
70
+ // is a one-line file naming THIS machine's git directory. The walk skips it by name, but a
71
+ // watcher event does not. Measured 30 Sep 2026 with '.git/' alone: an edit to A's sub/.git was
72
+ // published, then retracted as "deleted" by A's next safety-net scan (whose walk skips it) --
73
+ // and in between, peer B's OWN sub/.git, a submodule pointer to B's git directory, was first
74
+ // overwritten with conflict markers and then deleted. tests/branch-test.mjs pins it (D1, D2).
75
+ '.git',
57
76
  '.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
77
+ // Files that are credentials by name, whatever is in them on a given day: direnv's exports, the
78
+ // login stores of curl/git/pip, and the other private key formats.
79
+ '.envrc', '.netrc', '_netrc', '.git-credentials', '.pypirc', 'id_ecdsa', 'id_dsa', '*.ppk', '*.p8', '*.jks',
58
80
  '.hive-id.json', // this client's substrate signing identity (holds a PRIVATE key) — never sync it
59
81
  // This client's own session + chat read cursor. Syncing it would push one agent's identity
60
82
  // and read position into every other agent's folder, and a shared cursor means one agent
61
83
  // consuming another's unread mail.
62
84
  '.hive-mcp-session.json',
85
+ // The Stop hook's record of what it already held this agent's turn open for: per agent, like the session.
86
+ '.hive-stop-hook.json',
63
87
  // This client's record of what IT last wrote to each file — the common ancestor it uses
64
88
  // to tell an authored edit from its own write after a restart (see SHADOW_FILE). It is
65
89
  // per-client by definition: syncing it would hand every peer a false ancestor.
@@ -69,6 +93,10 @@ const ALWAYS_IGNORE = [
69
93
  // Names a PID on THIS machine. Syncing it would tell every peer their folder is locked by
70
94
  // a process that does not exist for them.
71
95
  '.hive-lock.json',
96
+ // Copies of files a teammate's delete removed from THIS folder (see keepDeletedCopy). They
97
+ // are this machine's safety net; syncing them would undo every delete in the room.
98
+ '.hive-deleted/',
99
+ '.hive-case-*',
72
100
  '*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
73
101
 
74
102
  // MOBILE AND CLOUD SECRETS. These are the files a mobile project keeps OUT of git via
@@ -136,14 +164,49 @@ export function fusionReason(merged, mine, theirs, lang) {
136
164
  return ''
137
165
  }
138
166
 
167
+ const endNl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
168
+
169
+ // HIV-P17. Why a creation race of relPath must NOT land, '' when it may: 'CONFLICT' when two whole
170
+ // files collide in a file with a language, else fusionReason's answer when a clean-looking union
171
+ // broke code both sides could parse. No language (Markdown) is never refused.
172
+ export function creationRaceVerdict(r0, mine, theirs, relPath) {
173
+ const code = supports(relPath)
174
+ return r0.conflict ? (code ? 'CONFLICT' : '') : fusionReason(r0.text, endNl(mine), endNl(theirs), code ? languageFor(relPath) : null)
175
+ }
176
+
177
+ // HIV-P17 dead heat: several clients published one file into an empty doc before any saw another, and
178
+ // the CRDT unioned them. textsByClient = each creator's live text, keyed by Yjs client id. The winner
179
+ // is the lowest id -- every peer holds the same union, so every peer picks the same one. The creators
180
+ // are folded in ONE BY ONE and each step is judged, because the union is what would land: with three
181
+ // creators, two can each merge cleanly with the winner and still fuse with each other.
182
+ export function deadHeatVerdict(textsByClient, relPath) {
183
+ const [winner, ...others] = [...textsByClient.keys()].sort((a, b) => a - b)
184
+ const winnerText = textsByClient.get(winner) || ''
185
+ let acc = winnerText
186
+ for (const c of others) {
187
+ const t = textsByClient.get(c) || ''
188
+ const r = icrMerge3('', endNl(acc), endNl(t), relPath)
189
+ const why = creationRaceVerdict(r, acc, t, relPath)
190
+ if (why) return { winner, winnerText, why }
191
+ acc = r.text
192
+ }
193
+ return { winner, winnerText, why: '' }
194
+ }
195
+
139
196
  const IGNORE = new Set(['node_modules', '.git'])
140
197
  const MAX_BYTES = 1_000_000
198
+ const MAX_BINARY_BYTES = Number(process.env.HIVE_MAX_BINARY_BYTES) || (5 * 1024 * 1024)
141
199
  const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never synced as a file
142
200
  const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
143
201
  const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
144
202
  const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
145
203
  const SHADOW_FILE = '.hive-shadow.json' // what THIS client last wrote per file; the cross-restart ancestor
146
204
  const LOCK_FILE = '.hive-lock.json' // one sync client per folder; see acquireFolderLock
205
+ const DELETED_DIR = '.hive-deleted' // copies of files a teammate's delete removed here; see keepDeletedCopy
206
+ const DELETED_KEEP_MS = 7 * 24 * 3600 * 1000 // ...pruned at start once they are a week old
207
+ // Local deletes wait this long for the deleting to stop, then leave as one batch. See settleLocalDeletes.
208
+ const DELETE_SETTLE_MS = Number(process.env.HIVE_DELETE_SETTLE_MS) || 1500
209
+ const BULK_DELETE = 5 // a batch this big is announced in the room, not just logged
147
210
  const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
148
211
  const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
149
212
  const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
@@ -155,11 +218,12 @@ const CLAIMS_FILE = 'HIVE_CLAIMS.md'
155
218
  const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
156
219
  // Generated/coordination files are rendered locally from CRDT state — never
157
220
  // synced as ordinary files (that would cause echo loops / conflicts).
158
- const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
221
+ // Exported for hive-branch.js: a tracked file with one of these names never reaches the room.
222
+ export const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
159
223
  // Files a client writes into its folder for ITSELF. They are ignored on purpose, so the "created
160
224
  // here but is ignored" notice must never name them: on 27 Sep 2026 every agent joining a live room
161
225
  // 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'])
226
+ const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-mcp-session.json', '.hive-stop-hook.json', '.hive-id.json'])
163
227
 
164
228
  // Written into every room folder on sync (like the rules). An AI agent that opens
165
229
  // this project reads it to learn it's a Hivecode room and how to JOIN — via the
@@ -239,29 +303,6 @@ it well enough to write it — say so in the room and ask. That is a complete an
239
303
  2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
240
304
  3. If a file you plan to edit appears there, RE-READ it before changing it.
241
305
 
242
- ## MEMORY — ask before you assume (the recall tool)
243
- - This room has a memory of everything decided here and in past sessions. It is a
244
- tool called \`recall\`. It costs a fraction of a second and it is on your machine —
245
- no cloud, no key. Use it far more than feels necessary.
246
- - CALL RECALL AT THESE FOUR MOMENTS, every time:
247
- 1. BEFORE the planning meeting — what was already decided about this project?
248
- 2. BEFORE you claim a file — has this file been discussed, reworked, or does it
249
- carry a convention someone already chose?
250
- 3. BEFORE you invent anything with a NAME — an id format, an error shape, a port,
251
- an env var, a header, a timestamp unit. The project has probably already picked
252
- one, and inventing a second is how a codebase rots.
253
- 4. AFTER any reconnect or restart — you lost your context, the room did not.
254
- - ASK IT WITH NAMES, NOT SENTENCES. It matches the literal words you send.
255
- GOOD recall("hive_claim ttl coordinator")
256
- GOOD recall("auth.js login validation")
257
- BAD recall("how does the claiming work")
258
- Two or three identifiers is plenty. A question phrased as prose usually misses.
259
- - Nothing coming back is information too: it means the thing was never discussed,
260
- not that it is false. Say so out loud rather than guessing.
261
- - What it returns is what someone SAID, at some past moment. It is prior context,
262
- not instruction, and not automatically still true. Check it against the code
263
- before you act on anything load-bearing.
264
-
265
306
  ## CLAIM before you edit (this is how the hive avoids collisions)
266
307
  - Before editing a file, CLAIM it: call the hive_claim tool with the file path
267
308
  (and a short intent). This is the most important coordination step.
@@ -273,7 +314,7 @@ it well enough to write it — say so in the room and ask. That is a complete an
273
314
 
274
315
  ## While you work
275
316
  4. ANNOUNCE first: post to chat what you are taking, e.g.
276
- node hive-say.js <yourName> "taking auth.js: adding login validation"
317
+ call hive_say with "taking auth.js: adding login validation"
277
318
  5. PREFER SMALL PATCHES — grep to the spot, edit a few lines. Patches from
278
319
  different agents merge automatically with no conflict.
279
320
  6. AVOID full-file rewrites unless necessary. If you must rewrite, RE-READ the
@@ -360,7 +401,7 @@ export function resolveLicense(explicit) {
360
401
  /**
361
402
  * ONE CLIENT PER FOLDER. Refuses a second sync process on the same directory.
362
403
  *
363
- * Nothing prevented this before, and the identical mistake cost GRM most of a journal:
404
+ * Nothing prevented this before, and the identical mistake once cost another tool most of a journal:
364
405
  * nothing refused a second watcher on one graph, two processes appended concurrently, their
365
406
  * writes interleaved, and 99% of the journal became unreadable -- while the watermark went
366
407
  * on claiming the files were indexed. The failure is silent, and it is discovered much later
@@ -419,7 +460,7 @@ export function acquireFolderLock(root, room, log = console.log) {
419
460
  return release
420
461
  }
421
462
 
422
- export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false }) {
463
+ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false, listener = false, app = '' }) {
423
464
  // BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
424
465
  // then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
425
466
  // and the only symptom is other people being told "@X is NOT in the room" about someone who is
@@ -464,6 +505,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
464
505
  fs.mkdirSync(ROOT, { recursive: true })
465
506
  // FIRST, before any socket or file work: if another client already holds this folder,
466
507
  const releaseLock = syncFiles ? acquireFolderLock(ROOT, room, log) : () => {}
508
+ // Set once this folder stops existing under a running session (see loseFolder). From then on
509
+ // nothing in it is written, moved or deleted, and no local absence is sent to the room as a delete.
510
+ let folderLost = null
511
+ if (syncFiles) pruneDeletedCopies()
467
512
 
468
513
  // Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
469
514
  // teammate's .env, keys, or build output. Reloaded by reloadIgnores().
@@ -552,12 +597,82 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
552
597
  // DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
553
598
  // whichever peer wrote last, which is precisely the failure it exists to catch.
554
599
  const swarmAttest = doc.getMap('swarm_attest')
600
+ const swarmVersions = doc.getMap('swarm_versions')
601
+
602
+ function bumpAttestVersion(relPath, newText) {
603
+ if (!swarmAttest.has(relPath)) return // only bump files that already have attestations
604
+ const prev = swarmVersions.get(relPath) || { n: 1, hash: '' }
605
+ const newHash = contentHash(newText)
606
+ if (prev.hash !== newHash) {
607
+ swarmVersions.set(relPath, { n: (prev.n || 1) + 1, hash: newHash })
608
+ }
609
+ }
555
610
  // GATE VERDICTS — `<region>|<peerName>` -> the result of that peer running the room's check in
556
611
  // ITS OWN folder before handing the file back. Keyed per peer for the same reason swarmAttest
557
612
  // is: "the tests pass" is a fact about one folder's bytes, and storing it as a single shared
558
613
  // value would converge on whoever released last, silently erasing the disagreement that is the
559
614
  // entire signal. Two agents reporting different verdicts for one path both survive here.
560
615
  const gateResults = doc.getMap('gate_results')
616
+
617
+ // --- BATCHED ROOM-DOC MUTATIONS (manifest + historyMeta) ---
618
+ // A seeder publishing N files previously sent N manifest.set() updates and N historyMeta.set()
619
+ // updates — 2N distinct Yjs updates on the base-room socket. On the free tier (burst 600, 60/s)
620
+ // this throttled the base room at file ~300.
621
+ // We batch pending operations into a single doc.transact() flushed on setImmediate (or synchronously on stop()).
622
+ const pendingManifestOps = new Map() // relPath -> 'set' | 'delete'
623
+ const pendingHistoryMeta = new Map() // id -> { op: 'set' | 'delete', record?: any }
624
+ let roomFlushTimer = null
625
+
626
+ function inManifest(relPath) {
627
+ if (pendingManifestOps.has(relPath)) {
628
+ return pendingManifestOps.get(relPath) === 'set'
629
+ }
630
+ return manifest.has(relPath)
631
+ }
632
+
633
+ function flushRoomDoc() {
634
+ if (roomFlushTimer) {
635
+ clearImmediate(roomFlushTimer)
636
+ roomFlushTimer = null
637
+ }
638
+ if (!pendingManifestOps.size && !pendingHistoryMeta.size) return
639
+ doc.transact(() => {
640
+ for (const [r, op] of pendingManifestOps) {
641
+ if (op === 'set') manifest.set(r, 1)
642
+ else manifest.delete(r)
643
+ }
644
+ pendingManifestOps.clear()
645
+
646
+ for (const [id, item] of pendingHistoryMeta) {
647
+ if (item.op === 'set') historyMeta.set(id, item.record)
648
+ else historyMeta.delete(id)
649
+ }
650
+ pendingHistoryMeta.clear()
651
+ pruneMeta()
652
+ })
653
+ }
654
+
655
+ function scheduleRoomFlush() {
656
+ if (stopped) { flushRoomDoc(); return }
657
+ if (!roomFlushTimer) {
658
+ roomFlushTimer = setImmediate(flushRoomDoc)
659
+ }
660
+ }
661
+
662
+ function queueManifestSet(relPath) {
663
+ pendingManifestOps.set(relPath, 'set')
664
+ scheduleRoomFlush()
665
+ }
666
+
667
+ function queueManifestDelete(relPath) {
668
+ if (!manifest.has(relPath)) {
669
+ pendingManifestOps.delete(relPath)
670
+ } else {
671
+ pendingManifestOps.set(relPath, 'delete')
672
+ }
673
+ scheduleRoomFlush()
674
+ }
675
+
561
676
  // CLAIM LEASE. Expiry is evaluated lazily on read, so the moment a lease passes its TTL the
562
677
  // next agent that tries simply gets the file -- that is what stops a stalled agent
563
678
  // deadlocking the room.
@@ -686,8 +801,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
686
801
  const base = shadow.get(relPath)
687
802
  if (base == null || disk === base) return false // no baseline yet, or nothing new on disk
688
803
  if (hasConflictMarkers(disk)) return false // half-edited conflict block — not an authored state
804
+ if (!SHARE_SECRETS && newSecrets(disk, fe.text.toString()).length) return false // the ledger is shared: a key stays out of it
689
805
  stampIntent(relPath, base, disk) // into the provenance DAG NOW — clobber-proof
806
+ // What this edit was made against, kept as a short HISTORY, not just the last one: a second edit
807
+ // while the first is still held would otherwise record the first (which the room never had) as
808
+ // the base, and a rejoin merging against it reverts the first edit. The rejoin picks the newest
809
+ // base the room's receipts prove it held.
810
+ const hist = capturedFrom.get(relPath) || []
811
+ if (hist[hist.length - 1] !== base) hist.push(base)
812
+ // Drop from the middle, never the oldest: while the room is refusing every write, the oldest is
813
+ // the only base the room actually held (antigravity-3: nine saves while held evicted it).
814
+ while (hist.length > FROM_KEEP) hist.splice(1, 1)
815
+ capturedFrom.set(relPath, hist)
690
816
  shadow.set(relPath, disk) // the captured state is the new baseline
817
+ saveShadowSoon()
691
818
  return true
692
819
  }
693
820
  // Build ONE clean N-way conflict block from a common parent and N divergent versions:
@@ -864,6 +991,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
864
991
  if (!canWrite(relPath)) return { ok: false, reason: `${relPath} is read-only for you` }
865
992
  if (typeof proposal !== 'string' || !proposal.trim()) return { ok: false, reason: 'empty resolution' }
866
993
  if (hasConflictMarkers(proposal)) return { ok: false, reason: 'resolution still contains <<<<<<< conflict markers — send the final clean file' }
994
+ if (!SHARE_SECRETS) {
995
+ const key = newSecrets(proposal, fe.text.toString())[0]
996
+ if (key) return { ok: false, reason: `the resolution contains ${describeSecret(key)}, and a resolution is sent to everyone in the room. Read the key from the environment (.env is never shared) and resolve again` }
997
+ }
867
998
  let landed = proposal
868
999
  if (supports(relPath) && rec.base != null) {
869
1000
  const v = structuralMerge(rec.base, proposal, rec.base, { filename: relPath })
@@ -913,6 +1044,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
913
1044
  relPath = String(relPath || '').replace(/\\/g, '/')
914
1045
  if (!relPath || typeof content !== 'string') return { error: 'need a file path and string content' }
915
1046
  if (!isSafeRelPath(relPath)) return { error: 'unsafe path' }
1047
+ if (folderLost) return { error: folderLost.message }
916
1048
  if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file` }
917
1049
  if (!canOpen(relPath)) return { error: `${relPath} is out of your scope` }
918
1050
  if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
@@ -928,7 +1060,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
928
1060
  const fe = fileDocs.get(relPath)
929
1061
  const landed = fe && fe.synced ? fe.text.toString() : content
930
1062
  let disk = content; try { disk = fs.readFileSync(full, 'utf8') } catch { }
931
- return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk) }
1063
+ // A new file's doc has not synced yet when this returns, so the gate in reconcile has not run:
1064
+ // judge the content here too, or the answer would be "syncing to every peer" for a file that
1065
+ // the gate is about to hold.
1066
+ const held = withheld.get(relPath) || (SHARE_SECRETS ? null : newSecrets(content, fe && fe.synced ? fe.text.toString() : '')[0])
1067
+ return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk), ...(held ? { withheld: describeSecret(held) } : {}) }
932
1068
  }
933
1069
  // Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
934
1070
  // this is monotonic + convergent: every peer contributes what it sees and the set grows
@@ -994,7 +1130,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
994
1130
  const fileTransport = createFileTransport({ relay, room, wsOpts, log, onFallback: () => trimForSockets() })
995
1131
  let stopped = false
996
1132
  const provider = new WebsocketProvider(relay, room, doc, wsOpts)
997
- provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, passive: !!passive }) // identity is implicit in the client
1133
+ // WHICH COPY OF AN AGENT THIS IS: this machine plus this folder, hashed. Everything in a room is
1134
+ // keyed by NAME (claims, mail, presence), so two different agents under one name are one identity
1135
+ // to everybody; this is what lets a join tell another agent with the same name (refused, see
1136
+ // sameNameElsewhere) from this agent a moment ago, restarting in the same folder (allowed).
1137
+ const IID = crypto.createHash('sha256').update(`${os.hostname()}|${ROOT}`).digest('hex').slice(0, 12)
1138
+ provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID }) // identity is implicit in the client
998
1139
 
999
1140
  // ---- relay reachability, said out loud exactly once -------------------------------
1000
1141
  // y-websocket retries forever and silently. That is the right RECOVERY behaviour and
@@ -1019,7 +1160,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1019
1160
  }
1020
1161
 
1021
1162
  function explain(code) {
1022
- if (code === 401) return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
1163
+ if (code === 401) {
1164
+ return token
1165
+ ? 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
1166
+ : 'this room needs a token of your own. Ask the room owner to mint you one.'
1167
+ }
1023
1168
  if (code === 402) return `this room is over its plan limit. Contact ${SUPPORT} and we will sort it out.`
1024
1169
  if (code === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
1025
1170
  // 429 at the UPGRADE is always Q431 "room at capacity" -- never rate limiting. The rate
@@ -1196,6 +1341,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1196
1341
  function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
1197
1342
  const r = String(relPath)
1198
1343
  if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
1344
+ if (collided.has(r)) {
1345
+ return Promise.resolve({
1346
+ ok: false,
1347
+ collided: true,
1348
+ file: r,
1349
+ reason: `${r} collides in case with ${collided.get(r).with} already on disk. A case-insensitive disk cannot hold both.`,
1350
+ })
1351
+ }
1199
1352
  const full = path.join(ROOT, r)
1200
1353
  // Already here: report it, and say so rather than implying we went and got it.
1201
1354
  if (fs.existsSync(full)) return Promise.resolve({ ok: true, alreadyLocal: true, file: r })
@@ -1245,6 +1398,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1245
1398
  const fdoc = new Y.Doc()
1246
1399
  const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
1247
1400
  const text = fdoc.getText('content')
1401
+ const bin = fdoc.getMap('bin')
1248
1402
  // `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
1249
1403
  // restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
1250
1404
  // `undo` is a per-file UndoManager tracking THIS client's local edits, so a
@@ -1272,16 +1426,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1272
1426
  // each peer's DISK — never into the shared text — so conflict markers can't fuse.
1273
1427
  const conflict = fdoc.getMap('conflict')
1274
1428
  const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
1275
- e = { doc: fdoc, provider: fprovider, text, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
1429
+ e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
1276
1430
  fileDocs.set(relPath, e)
1431
+ // From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
1432
+ // 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
1433
+ if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
1277
1434
  text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
1435
+ bin.observe((_ev, txn) => { if (!txn.local) reconcileBinary(relPath, 'remote') }) // remote binary edits
1278
1436
  // A divergent receipt can arrive slightly after (or before) the fused content — so
1279
1437
  // re-run the fork check whenever the provenance ledger changes too, not only on edits.
1280
1438
  ledger.observe((_ev, txn) => { if (!txn.local) detectFork(relPath) })
1281
1439
  // The fork record arrived/changed from a peer: render it locally, or — if it was
1282
1440
  // cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
1283
1441
  conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
1284
- fprovider.on('sync', (s) => { if (s) { e.synced = true; refusedFiles.delete(relPath); reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
1442
+ fprovider.on('sync', (s) => {
1443
+ if (s) {
1444
+ e.synced = true
1445
+ refusedFiles.delete(relPath)
1446
+ if (e.bin.has('data')) {
1447
+ reconcileBinary(relPath, 'remote')
1448
+ } else {
1449
+ reconcile(relPath, 'remote')
1450
+ detectFork(relPath)
1451
+ }
1452
+ }
1453
+ })
1285
1454
  // A refused upgrade lands here. The provider will keep retrying by itself, which is the
1286
1455
  // right behaviour and exactly why nothing surfaces it — so count it and let noteRefusals
1287
1456
  // decide whether this is a blip or a relay that will not take us.
@@ -1493,11 +1662,24 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1493
1662
  if (SKIP.has(p)) return `${p} is a Hivecode control file, not shared source`
1494
1663
  if (!canOpen(p)) return `your access token's scope does not cover ${p}, so its bytes stay in this folder`
1495
1664
  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`
1665
+ if (onDisk) {
1666
+ const full = path.join(ROOT, p)
1667
+ const unshared = checkUnshared(full)
1668
+ if (unshared) {
1669
+ if (unshared.reason === 'oversized') {
1670
+ return `${p} is oversized (${Math.round(unshared.size / 1024)}KB > 1MB limit), so it cannot be shared -- anything you write stays in this folder and no other agent will see it`
1671
+ }
1672
+ return `${p} contains binary data (null bytes), so it cannot be shared -- anything you write stays in this folder and no other agent will see it`
1673
+ }
1674
+ }
1496
1675
  return ''
1497
1676
  }
1498
1677
 
1499
1678
  const known = new Set()
1500
1679
  const mtimes = new Map()
1680
+ const createdTimes = new Map() // relPath -> timestamp of creation/genesis publish
1681
+ const genesisPublished = new Set() // relPath -> files THIS client genesis-published this session
1682
+ const deadHeatResolved = new Set() // relPath -> files where dead-heat creation-race detection already resolved
1501
1683
  // Paths that already existed on disk when this client STARTED. This is the
1502
1684
  // discriminator between "I cloned a repo and joined a room that already has these
1503
1685
  // files" (adopt the room's copy) and "this file was created during the session"
@@ -1511,6 +1693,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1511
1693
  let roomAtJoin = null
1512
1694
  let firstScanDone = false
1513
1695
  const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
1696
+ const shaShadow = new Map() // relPath -> sha256 hex string of binary bytes written/seen locally (echo suppression)
1697
+ // Per-file pre-serialized JSON fragments: relPath -> '"path": "content"'.
1698
+ // Assembled into v:1 JSON in saveShadow() without re-stringifying all files, cutting save CPU by ~6.6x.
1699
+ const shadowFragments = new Map()
1700
+ // relPath -> the bases our recent captured edits were made against, oldest first. Saved with the
1701
+ // shadow, because the shadow alone cannot tell "the room has this" from "we sent it and it never
1702
+ // arrived".
1703
+ const FROM_KEEP = 8
1704
+ const capturedFrom = new Map()
1705
+ const priorFrom = new Map()
1706
+ const keptUnsent = new Map() // relPath -> file mtime when an unsent edit was kept; frozen until saved again
1707
+ const origShadowSet = shadow.set.bind(shadow)
1708
+ const origShadowDelete = shadow.delete.bind(shadow)
1709
+ shadow.set = (k, v) => {
1710
+ origShadowSet(k, v)
1711
+ if (typeof v === 'string' && v.length <= MAX_BYTES) {
1712
+ shadowFragments.set(k, JSON.stringify(k) + ':' + JSON.stringify(v))
1713
+ } else {
1714
+ shadowFragments.delete(k)
1715
+ }
1716
+ return shadow
1717
+ }
1718
+ shadow.delete = (k) => {
1719
+ shadowFragments.delete(k)
1720
+ return origShadowDelete(k)
1721
+ }
1514
1722
  // THE SAME THING, FROM THE PREVIOUS SESSION, and the reason it exists:
1515
1723
  //
1516
1724
  // `shadow` in memory is enough to tell an authored edit from our own write while we are
@@ -1560,16 +1768,324 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1560
1768
 
1561
1769
  function readText(full) {
1562
1770
  try {
1771
+ const st = fs.statSync(full)
1772
+ if (st.size > MAX_BYTES) return null
1563
1773
  const buf = fs.readFileSync(full)
1564
- if (buf.length > MAX_BYTES || buf.includes(0)) return null
1774
+ if (buf.includes(0)) return null
1565
1775
  return buf.toString('utf8')
1566
1776
  } catch { return null }
1567
1777
  }
1778
+ // BRANCH ROOMS CARRY TEXT ONLY, BY DESIGN (hive-branch.js roomCarries): binaries are filled into
1779
+ // each folder from git, and `pr` reads a carried file missing from the room as DELETED. Binary sync
1780
+ // pushed every git-filled image into the room (tests/branch-test A6: 5 files where the seed put 4),
1781
+ // and once one was deleted there, the next joiner's fill would write it back and binary sync would
1782
+ // publish the undo to everyone. So binary sync is off in a branch-room folder, recognised by the
1783
+ // `.git` FILE hive-branch writes into it (NOT_A_CHECKOUT; not imported, hive-branch imports us).
1784
+ // A binary the room already holds (someone pushed it from a plain folder) is still received.
1785
+ const BINARY_SYNC = !process.env.HIVE_NO_BINARY_SYNC &&
1786
+ !(() => { try { return fs.readFileSync(path.join(ROOT, '.git'), 'utf8').includes('hivecode-live-room-not-a-git-checkout') } catch { return false } })()
1787
+ function isBinaryFile(full, st) {
1788
+ if (!BINARY_SYNC) return false
1789
+ try {
1790
+ const s = st || fs.statSync(full)
1791
+ if (!s.isFile()) return false
1792
+ if (s.size > MAX_BINARY_BYTES) return false
1793
+ const buf = fs.readFileSync(full)
1794
+ return buf.includes(0)
1795
+ } catch { return false }
1796
+ }
1797
+ function checkUnshared(full, st) {
1798
+ try {
1799
+ const s = st || fs.statSync(full)
1800
+ if (!s.isFile()) return null
1801
+ if (s.size > MAX_BINARY_BYTES) {
1802
+ return { reason: 'oversized', size: s.size }
1803
+ }
1804
+ const buf = fs.readFileSync(full)
1805
+ if (buf.includes(0)) {
1806
+ if (!BINARY_SYNC) {
1807
+ return { reason: 'binary', size: s.size }
1808
+ }
1809
+ return null
1810
+ }
1811
+ if (s.size > MAX_BYTES) {
1812
+ return { reason: 'oversized', size: s.size }
1813
+ }
1814
+ return null
1815
+ } catch { return null }
1816
+ }
1817
+ const ensuredDirs = new Set()
1818
+ // A CREDENTIAL INSIDE A FILE STAYS ON THIS MACHINE (3 Oct 2026). A file in a room goes to the relay
1819
+ // and to every participant's disk, so a key an agent pasted into config.js "to try something" went
1820
+ // to all of them. Now a file whose content holds a credential the room does not already have
1821
+ // (hive-secrets.js) is not sent: not its first version, not its edits, and a teammate's edits to it
1822
+ // wait too, because a merge made here would carry the key out with them. The person is told once
1823
+ // which file and what kind of key -- never the key itself -- and the file flows again the moment
1824
+ // the key is out of it. A key already in the room holds nothing back: there is nothing left to keep.
1825
+ const SHARE_SECRETS = process.env.HIVE_SHARE_SECRETS === '1' // an explicit opt-out, never a default
1826
+ const withheld = new Map() // relPath -> { kind, line, said } of the first credential found
1827
+ function heldForSecret(relPath, local, shared) {
1828
+ if (SHARE_SECRETS) return false
1829
+ const found = newSecrets(local, shared)
1830
+ if (!found.length) { if (withheld.has(relPath)) clearWithheld(relPath); return false }
1831
+ const prev = withheld.get(relPath)
1832
+ // Said in the room only for a file the room HAS: there, teammates are waiting on a change that is
1833
+ // not coming. A file the room never had leaves nobody missing anything -- and a repo with a
1834
+ // committed key would otherwise have every teammate who checks it out announce the same file.
1835
+ // The agent is told either way (hive_wait, hive_edit).
1836
+ const said = prev ? prev.said : (!SKIP.has(relPath) && inManifest(relPath))
1837
+ const f = { kind: found[0].kind, line: found[0].line, said }
1838
+ withheld.set(relPath, f)
1839
+ if (!prev) {
1840
+ log(`[${name}] holding ${relPath} on this machine: it contains ${describeSecret(f)}`)
1841
+ if (said) { try { say(`⛔ ${relPath}: ${name}'s change stays on ${name}'s machine, because it contains ${describeSecret(f)} and Hivecode does not send keys to the room. It is shared once the key is moved out of the file (into .env, which is never shared).`) } catch { } }
1842
+ }
1843
+ return true
1844
+ }
1845
+ function clearWithheld(relPath) {
1846
+ const w = withheld.get(relPath)
1847
+ withheld.delete(relPath)
1848
+ log(`[${name}] ${relPath} holds no key now: shared again`)
1849
+ if (w && w.said) { try { say(`· ${relPath} from ${name} is shared again: the key is out of it.`) } catch { } }
1850
+ }
1851
+ // Still on disk: a held file that was since deleted or moved away is no longer anybody's concern.
1852
+ function withheldNow() {
1853
+ return [...withheld].filter(([p]) => fs.existsSync(path.join(ROOT, p))).map(([p, f]) => ({ path: p, kind: f.kind, line: f.line }))
1854
+ }
1855
+ // A ROOM WRITE THAT CANNOT LAND HERE. Measured 3 Oct 2026: a teammate's edit to a file that is
1856
+ // read-only on this disk (a Perforce checkout, a generated file, a handle another program holds)
1857
+ // threw EPERM inside the Yjs observer; y-websocket printed "Caught error while handling a Yjs
1858
+ // update" to stderr, the file stayed at the old version while the room moved on, status() read
1859
+ // all green, and nothing ever tried again. Now the path is recorded, the room is told once that
1860
+ // this copy is behind, status() lists it, and every scan retries (retryUnwritable) until it lands.
1861
+ const unwritable = new Map() // relPath -> error code of the last failed write
1862
+ function noteUnwritable(relPath, e) {
1863
+ const code = (e && e.code) || 'error'
1864
+ const first = !unwritable.has(relPath)
1865
+ unwritable.set(relPath, code)
1866
+ if (!first) return
1867
+ log(`[${name}] could not write ${relPath} (${code}); this folder's copy stays behind the room's until it can be written`)
1868
+ if (SKIP.has(relPath)) return // a rendered control file: it is re-rendered on the next change anyway
1869
+ try { say(`⚠ ${relPath} could not be written on ${name}'s disk (${code}: read-only, locked, or a folder in the way), so ${name}'s copy is older than the room's. It is written as soon as the file can be.`) } catch { }
1870
+ }
1871
+ function clearUnwritable(relPath) {
1872
+ unwritable.delete(relPath)
1873
+ log(`[${name}] ${relPath} written: this folder has the room's copy again`)
1874
+ if (!SKIP.has(relPath)) { try { say(`· ${relPath} is current on ${name}'s disk again.`) } catch { } }
1875
+ }
1876
+ // Through reconcile, not a bare write: if the file was edited here while it could not be written,
1877
+ // that edit is merged with the room's version rather than overwritten by it.
1878
+ function retryUnwritable() {
1879
+ for (const r of [...unwritable.keys()]) {
1880
+ if (SKIP.has(r)) { unwritable.delete(r); continue }
1881
+ const fe = fileDocs.get(r)
1882
+ const disk = readText(path.join(ROOT, r))
1883
+ if (fe && fe.synced && disk !== null && disk === fe.text.toString()) { clearUnwritable(r); continue }
1884
+ try { reconcile(r, 'remote') } catch { }
1885
+ }
1886
+ }
1887
+ // --- CASE-INSENSITIVE PATH COLLISION GUARD ---
1888
+ // On case-insensitive file systems (Windows/macOS), two room paths that differ only
1889
+ // in case (e.g. Case.js and case.js) map to the exact same file on disk. Writing both
1890
+ // causes alternating disk writes and watcher echo loops that clobber the room doc.
1891
+ // We probe case-sensitivity at startup, keep the key already on disk (or first seen),
1892
+ // never write the collided key to disk, never let a scan push the physical file into
1893
+ // the collided key, report collided paths in status(), and announce once to the room.
1894
+ const isCaseInsensitive = (() => {
1895
+ try {
1896
+ const probeName = `.hive-case-probe-${process.pid}-${Date.now()}.tmp`
1897
+ const probePath = path.join(ROOT, probeName)
1898
+ fs.writeFileSync(probePath, '')
1899
+ const probeLower = probePath.toLowerCase()
1900
+ const probeTarget = probePath === probeLower ? probePath.toUpperCase() : probeLower
1901
+ const insensitive = fs.existsSync(probeTarget)
1902
+ try { fs.unlinkSync(probePath) } catch { }
1903
+ return insensitive
1904
+ } catch {
1905
+ return process.platform === 'win32' || process.platform === 'darwin'
1906
+ }
1907
+ })()
1908
+ const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
1909
+
1910
+ const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
1911
+ const collided = new Map() // collided relPath -> { with: canonical relPath }
1912
+ const collidedWarned = new Set() // relPath announced to room
1913
+ const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
1914
+
1915
+ function clearCollidedTimer(r) {
1916
+ if (collidedTimers.has(r)) {
1917
+ clearTimeout(collidedTimers.get(r))
1918
+ collidedTimers.delete(r)
1919
+ }
1920
+ }
1921
+
1922
+ function actualDiskPath(relPath) {
1923
+ if (!isCaseInsensitive) return fs.existsSync(path.join(ROOT, relPath)) ? relPath : null
1924
+ const full = path.join(ROOT, relPath)
1925
+ if (!fs.existsSync(full)) return null
1926
+ try {
1927
+ const parts = relPath.replace(/\\/g, '/').split('/').filter(Boolean)
1928
+ let cur = ROOT
1929
+ const matched = []
1930
+ for (const part of parts) {
1931
+ const lower = part.toLowerCase()
1932
+ const entries = fs.readdirSync(cur)
1933
+ const hit = entries.find(e => e.toLowerCase() === lower)
1934
+ if (!hit) return relPath
1935
+ matched.push(hit)
1936
+ cur = path.join(cur, hit)
1937
+ }
1938
+ return matched.join('/')
1939
+ } catch {
1940
+ return relPath
1941
+ }
1942
+ }
1943
+
1944
+ function fileExistsExact(relPath) {
1945
+ if (!isCaseInsensitive) return fs.existsSync(path.join(ROOT, relPath))
1946
+ return actualDiskPath(relPath) === relPath
1947
+ }
1948
+
1949
+ function ensureExactDiskCasing(relPath) {
1950
+ if (!isCaseInsensitive) return
1951
+ const onDisk = actualDiskPath(relPath)
1952
+ if (onDisk && onDisk !== relPath && fold(onDisk) === fold(relPath)) {
1953
+ const full = path.join(ROOT, relPath)
1954
+ const onDiskFull = path.join(ROOT, onDisk)
1955
+ const dir = path.dirname(full)
1956
+ const hop = path.join(dir, `.hive-case-hop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
1957
+ try {
1958
+ fs.renameSync(onDiskFull, hop)
1959
+ try {
1960
+ fs.renameSync(hop, full)
1961
+ } catch {
1962
+ // If second rename failed, attempt to rename the hop back to onDiskFull
1963
+ try { fs.renameSync(hop, onDiskFull) } catch { }
1964
+ }
1965
+ } catch { }
1966
+ }
1967
+ }
1968
+
1969
+ function checkCaseCollision(r) {
1970
+ if (!isCaseInsensitive) return false
1971
+ const cf = fold(r)
1972
+ const canon = canonicalCaseKeys.get(cf)
1973
+ if (canon === r) return false
1974
+
1975
+ const onDisk = actualDiskPath(r)
1976
+
1977
+ if (!canon) {
1978
+ if (onDisk) {
1979
+ canonicalCaseKeys.set(cf, onDisk)
1980
+ if (onDisk !== r) {
1981
+ collided.set(r, { with: onDisk })
1982
+ noteCollided(r, onDisk)
1983
+ return true
1984
+ }
1985
+ return false
1986
+ }
1987
+ canonicalCaseKeys.set(cf, r)
1988
+ return false
1989
+ }
1990
+
1991
+ // We have a difference between `canon` and `r` for the same folded name.
1992
+ if (onDisk === r && onDisk !== canon) {
1993
+ canonicalCaseKeys.set(cf, r)
1994
+ collided.delete(r)
1995
+ collidedWarned.delete(r)
1996
+ clearCollidedTimer(r)
1997
+
1998
+ // If `canon` is absent on disk with exact case, it was locally renamed to `r`
1999
+ if (actualDiskPath(canon) !== canon) {
2000
+ collided.delete(canon)
2001
+ collidedWarned.delete(canon)
2002
+ clearCollidedTimer(canon)
2003
+ if (!inManifest(r)) {
2004
+ queueManifestSet(r)
2005
+ }
2006
+ noteLocalDelete(canon)
2007
+ return false
2008
+ }
2009
+
2010
+ collided.set(canon, { with: r })
2011
+ noteCollided(canon, r)
2012
+ return false
2013
+ }
2014
+
2015
+ collided.set(r, { with: canon })
2016
+ noteCollided(r, canon)
2017
+ return true
2018
+ }
2019
+
2020
+ function noteCollided(r, withKey) {
2021
+ if (collidedWarned.has(r)) return
2022
+ log(`[${name}] ${withKey} and ${r} both exist in the room; disk cannot hold both, so ${r} is not written here`)
2023
+ if (SKIP.has(r)) return
2024
+ if (collidedTimers.has(r)) return
2025
+ collidedTimers.set(r, setTimeout(() => {
2026
+ collidedTimers.delete(r)
2027
+ if (!collided.has(r) || collidedWarned.has(r)) return
2028
+ if (!inManifest(r) || !inManifest(withKey)) return
2029
+ collidedWarned.add(r)
2030
+ try {
2031
+ say(`${withKey} and ${r} both exist in the room; ${name}'s disk cannot hold both, so ${r} is not written there.`)
2032
+ } catch { }
2033
+ }, 2500))
2034
+ }
2035
+
1568
2036
  function writeToDisk(relPath, content) {
2037
+ if (collided.has(relPath)) return
1569
2038
  if (!isSafeRelPath(relPath)) return // never write a path that could escape ROOT (defense in depth)
2039
+ // The mkdir below would recreate a lost folder holding one file, and the next scan would read
2040
+ // every other file as deleted -- the exact wipe loseFolder exists to prevent.
2041
+ if (folderLost) return
2042
+ ensureExactDiskCasing(relPath)
1570
2043
  const full = path.join(ROOT, relPath)
1571
- fs.mkdirSync(path.dirname(full), { recursive: true })
1572
- fs.writeFileSync(full, content)
2044
+ try {
2045
+ // Only for a path a scan already found unshared. Every remote edit lands through here, so
2046
+ // checking every file would read each one in full before writing it -- double the disk work
2047
+ // on the hottest path, to guard a case the scan has already seen.
2048
+ if ((unsharedMtimes.has(relPath) || unsharedWarned.has(relPath)) && fs.existsSync(full)) {
2049
+ const unshared = checkUnshared(full)
2050
+ if (unshared) {
2051
+ const stamp = deletedStamp()
2052
+ const keptAs = keepDeletedCopy(relPath, stamp)
2053
+ if (keptAs) {
2054
+ log(`[${name}] local unshared file ${relPath} overwritten by room write; copy preserved in ${keptAs}`)
2055
+ try {
2056
+ say(`· ${relPath} on ${name}'s disk was ${unshared.reason} and was overwritten by a room write — local copy preserved in ${keptAs}.`)
2057
+ } catch { }
2058
+ }
2059
+ }
2060
+ }
2061
+ } catch { }
2062
+ const dir = path.dirname(full)
2063
+ try {
2064
+ if (!ensuredDirs.has(dir)) {
2065
+ fs.mkdirSync(dir, { recursive: true })
2066
+ ensuredDirs.add(dir)
2067
+ }
2068
+ try { fs.writeFileSync(full, content) } catch (e) {
2069
+ // The cache only skips mkdir; it must never lose a write. A folder someone deleted or renamed
2070
+ // since we made it is simply made again.
2071
+ if (e.code !== 'ENOENT') throw e
2072
+ ensuredDirs.delete(dir)
2073
+ fs.mkdirSync(dir, { recursive: true })
2074
+ ensuredDirs.add(dir)
2075
+ fs.writeFileSync(full, content)
2076
+ }
2077
+ } catch (e) {
2078
+ // The disk still holds the OLD content. Callers record the new content as this file's base and
2079
+ // shadow -- some before writing, some after -- and a base the disk does not hold makes the stale
2080
+ // copy read as a local edit, merged back over the room: measured, a teammate's change reverted
2081
+ // for everyone. So the bookkeeping goes back to what the disk really holds, and the error still
2082
+ // propagates, so no caller goes on to record this write as done.
2083
+ const onDisk = readText(full)
2084
+ if (onDisk !== null) { bases.set(relPath, onDisk); forkBases.set(relPath, onDisk); shadow.set(relPath, onDisk); saveShadowSoon() }
2085
+ noteUnwritable(relPath, e)
2086
+ throw e
2087
+ }
2088
+ if (unwritable.has(relPath)) clearUnwritable(relPath)
1573
2089
  shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
1574
2090
  saveShadowSoon() // ...and survive a restart, or the next join has no ancestor
1575
2091
  known.add(relPath)
@@ -1590,6 +2106,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1590
2106
  for (const [k, v] of Object.entries(o.files || {})) {
1591
2107
  if (typeof k === 'string' && typeof v === 'string' && isSafeRelPath(k)) priorShadow.set(k, v)
1592
2108
  }
2109
+ // Carried forward, not just read: an edit re-sent after a rejoin can be lost the same way
2110
+ // again, and the next rejoin needs the same base.
2111
+ for (const [k, v] of Object.entries(o.from || {})) {
2112
+ const hist = (Array.isArray(v) ? v : [v]).filter((s) => typeof s === 'string').slice(-FROM_KEEP)
2113
+ if (typeof k === 'string' && hist.length && isSafeRelPath(k)) { priorFrom.set(k, hist); capturedFrom.set(k, hist.slice()) }
2114
+ }
1593
2115
  log(`[hive] recovered ${priorShadow.size} file ancestor(s) from the previous session`)
1594
2116
  } catch { /* absent or unreadable: no ancestor, same as before this existed */ }
1595
2117
  }
@@ -1600,15 +2122,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1600
2122
  if (shadowTimer.unref) shadowTimer.unref() // never hold the process open just to save this
1601
2123
  }
1602
2124
  function saveShadow() {
1603
- const files = {}
1604
- // Only what we could actually merge against later. A file over MAX_BYTES is not synced
1605
- // in the first place, so storing it would inflate this file for no benefit.
1606
- for (const [k, v] of shadow) if (typeof v === 'string' && v.length <= MAX_BYTES) files[k] = v
1607
2125
  const tmp = path.join(ROOT, SHADOW_FILE + '.tmp')
1608
2126
  try {
1609
- // Write-then-rename: a half-written ancestor file is worse than none, because the
1610
- // loader would read a truncated body as the real previous content and merge against it.
1611
- fs.writeFileSync(tmp, JSON.stringify({ v: 1, room, files }))
2127
+ // Assemble v:1 JSON from cached fragments: avoids walking/stringifying all files
2128
+ // on every debounced flush. The resulting payload is byte-for-byte valid JSON matching
2129
+ // { v: 1, room, files }, preserving the atomic write-then-rename.
2130
+ const parts = []
2131
+ for (const frag of shadowFragments.values()) parts.push(frag)
2132
+ const from = []
2133
+ for (const [k, v] of capturedFrom) {
2134
+ const hist = v.filter((s) => typeof s === 'string' && s.length <= MAX_BYTES)
2135
+ if (hist.length) from.push(JSON.stringify(k) + ':' + JSON.stringify(hist))
2136
+ }
2137
+ const payload = `{"v":1,"room":${JSON.stringify(room)},"files":{${parts.join(',')}},"from":{${from.join(',')}}}`
2138
+ fs.writeFileSync(tmp, payload)
1612
2139
  fs.renameSync(tmp, path.join(ROOT, SHADOW_FILE))
1613
2140
  } catch (e) {
1614
2141
  try { fs.unlinkSync(tmp) } catch { }
@@ -1618,13 +2145,229 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1618
2145
 
1619
2146
  const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
1620
2147
  const icrWarned = new Map() // relPath -> last ICR semantic warning announced (dedupe)
2148
+ // HIV-P17: a creation race of CODE that cannot merge lands NOTHING. The doc keeps roomText, the disk
2149
+ // keeps the local draft (also saved as a manual snapshot, so it survives whatever happens next), and
2150
+ // the bases point at roomText, so the next normal edit merges against the room's copy. One function
2151
+ // for all three race paths -- first contact, a dead heat, and a dead heat the peer already resolved
2152
+ // -- so their recovery and their announcement cannot drift apart.
2153
+ // An edit the room never received, and we no longer know what it was made against: re-sending
2154
+ // it as a diff over the room's copy could revert someone else's change, so it is not sent. It is
2155
+ // kept on disk and in the file's history, and said out loud, because the alternative was silence.
2156
+ // Not "save it again": the base is now the disk, so an unchanged save is a no-op and sends nothing
2157
+ // (Antigravity, 1 Nov). hive_edit writes the copy into the room outright. Both copies go into the
2158
+ // file's history so the two can be compared before choosing.
2159
+ function keepUnsent(relPath, disk, roomText) {
2160
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local edit that never reached the room (kept)' })
2161
+ if (typeof roomText === 'string' && roomText !== disk) captureSnapshot(relPath, roomText, name, { force: true, kind: 'manual', label: 'room copy when the local edit was kept' })
2162
+ bases.set(relPath, disk); forkBases.set(relPath, disk)
2163
+ let mt = 0; try { mt = fs.statSync(path.join(ROOT, relPath)).mtimeMs } catch { }
2164
+ keptUnsent.set(relPath, mt)
2165
+ try {
2166
+ say(`⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
2167
+ `The room's copy was NOT applied over it: it is kept on disk, and both copies are in the file's history. Nothing more syncs for this file until ` +
2168
+ `${name} saves it again; that save sends it as it is, replacing the room's copy. Claim ${relPath} first.`)
2169
+ } catch { }
2170
+ }
2171
+ function refuseCreationRace(relPath, why, disk, roomText) {
2172
+ conflicted.add(relPath)
2173
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local draft preserved (creation-race refused)' })
2174
+ bases.set(relPath, roomText); forkBases.set(relPath, roomText); shadow.set(relPath, roomText)
2175
+ if (!inManifest(relPath)) queueManifestSet(relPath)
2176
+ if (icrWarned.get(relPath) === why) return
2177
+ icrWarned.set(relPath, why)
2178
+ const conflict = why === 'CONFLICT'
2179
+ log(`[${name}] !! HIV-P17 REFUSED a ${conflict ? 'conflicting' : 'fused'} creation-race merge of ${relPath}${conflict ? '' : `: ${why}`}`)
2180
+ try {
2181
+ say(conflict
2182
+ ? `[!] @all ${relPath}: a creation-race merge was REFUSED — two participants created it with conflicting content, and landing both whole files in the room's copy would fuse them. 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.`
2183
+ : `[!] @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.`)
2184
+ } catch { /* the log still carries it */ }
2185
+ }
2186
+ // ONE MESSAGE PER JOIN, IN PLAIN WORDS (onboarding audit, 2 Oct 2026). HIV-P9's outcomes (in
2187
+ // reconcile, below) are right, but each file used to be announced on its own chat line in engine
2188
+ // words ("UNPROVEN", "Resolve deliberately"), so a teammate a few commits behind filled the chat,
2189
+ // and a Windows checkout (CRLF, against a room seeded from git's LF) made nearly every text file
2190
+ // "differ". The outcomes are unchanged. They are collected until the join goes quiet and said
2191
+ // once; the per-file log lines stay. Line endings alone are not worth a chat line: when nothing
2192
+ // else differs, nothing is said. The window slides, so one join of a big repo stays one message.
2193
+ const firstContact = { eol: [], adopted: [], kept: [], timer: null }
2194
+ function noteFirstContact(kind, relPath) {
2195
+ firstContact[kind].push(relPath)
2196
+ if (firstContact.timer) clearTimeout(firstContact.timer)
2197
+ firstContact.timer = setTimeout(flushFirstContact, 3000)
2198
+ }
2199
+ function flushFirstContact() {
2200
+ if (firstContact.timer) { clearTimeout(firstContact.timer); firstContact.timer = null }
2201
+ const { eol, adopted, kept } = firstContact
2202
+ firstContact.eol = []; firstContact.adopted = []; firstContact.kept = []
2203
+ if (!adopted.length && !kept.length) return
2204
+ const total = eol.length + adopted.length + kept.length
2205
+ const names = (l) => l.slice(0, 5).join(', ') + (l.length > 5 ? ` and ${l.length - 5} more` : '')
2206
+ const parts = [`↓ ${name} joined with ${total === 1 ? '1 file that differs' : `${total} files that differ`} from the room's copy.`]
2207
+ if (eol.length) parts.push(`${eol.length} differed only in line endings and now match the room.`)
2208
+ if (adopted.length) {
2209
+ parts.push(`${adopted.length === 1 ? '1 was an older version' : `${adopted.length} were older versions`} the room had already moved past, so ${name} now has the room's copy (the old one is in the file's history): ${names(adopted)}.`)
2210
+ }
2211
+ if (kept.length) {
2212
+ const one = kept.length === 1
2213
+ parts.push(`${one ? '1 holds' : `${kept.length} hold`} content the room has never seen, so neither copy was changed and the room's copy was NOT adopted: ${names(kept)}. ` +
2214
+ `In ${name}'s folder ${one ? 'it still holds' : 'they still hold'} ${name}'s version. To make one the room's version, claim it and hive_edit it with the full file. ` +
2215
+ `Don't delete a local copy to get the room's: a deletion syncs, and removes the file for everyone.`)
2216
+ }
2217
+ try { say(parts.join(' ')) } catch { }
2218
+ }
2219
+
2220
+ function reconcileBinary(relPath, origin = 'local') {
2221
+ if (folderLost) return
2222
+ if (SKIP.has(relPath) || isIgnored(relPath) || !canOpen(relPath)) return
2223
+ if (collided.has(relPath)) return
2224
+ const full = path.join(ROOT, relPath)
2225
+ const exists = !isCaseInsensitive ? fs.existsSync(full) : actualDiskPath(relPath) === relPath
2226
+ let fe = fileDocs.get(relPath)
2227
+ if (!fe) {
2228
+ if (!exists) return
2229
+ openFile(relPath)
2230
+ fe = fileDocs.get(relPath)
2231
+ if (!fe) return
2232
+ }
2233
+ if (!fe.synced && origin !== 'local') return
2234
+ touchFile(relPath)
2235
+
2236
+ if (origin === 'local') {
2237
+ if (!canWrite(relPath)) {
2238
+ if (fe && fe.synced && fe.bin.has('data')) {
2239
+ const remoteData = fe.bin.get('data')
2240
+ const remoteSha = fe.bin.get('sha')
2241
+ if (remoteData && remoteSha) {
2242
+ let localSha = null
2243
+ if (exists) {
2244
+ try {
2245
+ const localBuf = fs.readFileSync(full)
2246
+ localSha = crypto.createHash('sha256').update(localBuf).digest('hex')
2247
+ } catch { }
2248
+ }
2249
+ if (localSha !== remoteSha) {
2250
+ try {
2251
+ fs.writeFileSync(full, Buffer.from(remoteData))
2252
+ shaShadow.set(relPath, remoteSha)
2253
+ mtimes.set(relPath, fs.statSync(full).mtimeMs)
2254
+ } catch { }
2255
+ }
2256
+ } else {
2257
+ ensureExactDiskCasing(relPath)
2258
+ }
2259
+ }
2260
+ return
2261
+ }
2262
+ if (!exists) return
2263
+ let st
2264
+ try { st = fs.statSync(full) } catch { return }
2265
+ if (!st.isFile()) return
2266
+ if (st.size > MAX_BINARY_BYTES) {
2267
+ noticeUnsharedOne(relPath, { reason: 'oversized', size: st.size })
2268
+ return
2269
+ }
2270
+ let buf
2271
+ try { buf = fs.readFileSync(full) } catch { return }
2272
+ const sha = crypto.createHash('sha256').update(buf).digest('hex')
2273
+ if (shaShadow.get(relPath) === sha) return // echo suppression
2274
+ shaShadow.set(relPath, sha)
2275
+ mtimes.set(relPath, st.mtimeMs)
2276
+
2277
+ fe.doc.transact(() => {
2278
+ fe.bin.set('data', new Uint8Array(buf))
2279
+ fe.bin.set('sha', sha)
2280
+ fe.bin.set('size', buf.length)
2281
+ fe.bin.set('by', name)
2282
+ fe.bin.set('at', Date.now())
2283
+ if (fe.text.length > 0) fe.text.delete(0, fe.text.length)
2284
+ })
2285
+ if (!inManifest(relPath)) manifest.set(relPath, 1)
2286
+ known.add(relPath)
2287
+ dischargeUnshared(relPath)
2288
+ return
2289
+ }
2290
+
2291
+ // origin === 'remote'
2292
+ if (!fe.bin.has('data')) return
2293
+ const remoteData = fe.bin.get('data')
2294
+ const remoteSha = fe.bin.get('sha')
2295
+ const remoteBy = fe.bin.get('by')
2296
+ if (!remoteData || !remoteSha) return
2297
+
2298
+ let localSha = null
2299
+ if (exists) {
2300
+ try {
2301
+ const localBuf = fs.readFileSync(full)
2302
+ localSha = crypto.createHash('sha256').update(localBuf).digest('hex')
2303
+ } catch { }
2304
+ }
2305
+
2306
+ if (localSha === remoteSha) {
2307
+ shaShadow.set(relPath, remoteSha)
2308
+ try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
2309
+ known.add(relPath)
2310
+ return
2311
+ }
2312
+
2313
+ let keptAs = null
2314
+ if (exists) {
2315
+ const stamp = deletedStamp()
2316
+ keptAs = keepDeletedCopy(relPath, stamp)
2317
+ if (keptAs) {
2318
+ log(`[${name}] local binary ${relPath} overwritten by remote write (winner: ${remoteBy || 'teammate'}); copy preserved in ${keptAs}`)
2319
+ try {
2320
+ say(`· ${relPath} updated from room (winner: ${remoteBy || 'teammate'}). Your local copy saved to ${keptAs}.`)
2321
+ } catch { }
2322
+ }
2323
+ }
2324
+
2325
+ try {
2326
+ fs.mkdirSync(path.dirname(full), { recursive: true })
2327
+ fs.writeFileSync(full, Buffer.from(remoteData))
2328
+ shaShadow.set(relPath, remoteSha)
2329
+ mtimes.set(relPath, fs.statSync(full).mtimeMs)
2330
+ known.add(relPath)
2331
+ dischargeUnshared(relPath)
2332
+ log(`[${name}] <- binary ${relPath} (${remoteData.length} bytes${keptAs ? `, previous saved to ${keptAs}` : ''})`)
2333
+ } catch (err) {
2334
+ log(`[${name}] error writing binary ${relPath}: ${err.message}`)
2335
+ }
2336
+ }
2337
+
1621
2338
  function reconcile(relPath, origin = 'local') {
2339
+ if (process.env.HIVE_TEST_STUCK_SYNC && relPath === process.env.HIVE_TEST_STUCK_SYNC) return
1622
2340
  if (SKIP.has(relPath) || isIgnored(relPath)) return // never sync secrets/ignored files
1623
2341
  if (!canOpen(relPath)) return // out-of-scope OR unsafe path (traversal/control char) — never materialize it
2342
+ if (collided.has(relPath)) {
2343
+ if (origin === 'local') return // never push physical file into collided key
2344
+ const fe = fileDocs.get(relPath)
2345
+ if (fe && fe.synced) {
2346
+ const dt = fe.text.toString()
2347
+ bases.set(relPath, dt)
2348
+ forkBases.set(relPath, dt)
2349
+ }
2350
+ return
2351
+ }
1624
2352
  const full = path.join(ROOT, relPath)
1625
- const exists = fs.existsSync(full)
2353
+ const exists = !isCaseInsensitive ? fs.existsSync(full) : actualDiskPath(relPath) === relPath
2354
+ let fe = fileDocs.get(relPath)
2355
+ if (fe && fe.bin && fe.bin.has('data')) {
2356
+ reconcileBinary(relPath, origin)
2357
+ return
2358
+ }
1626
2359
  const disk = exists ? readText(full) : null
1627
- if (exists && disk === null) return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
2360
+ if (exists && disk === null) {
2361
+ if (isBinaryFile(full)) {
2362
+ if (origin === 'local') {
2363
+ reconcileBinary(relPath, 'local')
2364
+ return
2365
+ }
2366
+ }
2367
+ const unshared = checkUnshared(full)
2368
+ if (unshared) noticeUnsharedOne(relPath, unshared)
2369
+ return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
2370
+ }
1628
2371
  // View-only file: never PUSH our local change/creation (the relay would drop it
1629
2372
  // anyway — it connects this file-room as a reader). Keep disk in sync with the
1630
2373
  // shared copy if we already have it; a brand-new local-only file just stays local
@@ -1639,9 +2382,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1639
2382
  // "empty file" — acting now would re-seed from disk and fork the CRDT history
1640
2383
  // (duplicated/garbled content on rejoin). So: open if needed, then defer until
1641
2384
  // the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
1642
- let fe = fileDocs.get(relPath)
2385
+ fe = fileDocs.get(relPath)
1643
2386
  if (!fe) { if (disk === null) return; openFile(relPath); return }
1644
2387
  if (!fe.synced) return
2388
+ // A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
2389
+ // room in either direction would decide something nobody decided: the room's next change would
2390
+ // be written over it, and an unchanged save merged against itself pulled the room's old copy over
2391
+ // it (antigravity-3, 1 Nov). The next save is the decision: it sends the file as it is.
2392
+ if (keptUnsent.has(relPath)) {
2393
+ if (origin !== 'local') return
2394
+ let mt = 0; try { mt = fs.statSync(full).mtimeMs } catch { }
2395
+ if (mt === keptUnsent.get(relPath)) return
2396
+ keptUnsent.delete(relPath)
2397
+ const rt = fe.text.toString()
2398
+ captureSnapshot(relPath, rt, name, { force: true, kind: 'manual', label: 'room copy replaced by a kept edit' })
2399
+ bases.set(relPath, rt); forkBases.set(relPath, rt); shadow.set(relPath, rt)
2400
+ try { say(`↻ ${relPath}: ${name} saved the kept copy again, so it now replaces the room's copy (the room's copy is in the file's history).`) } catch { }
2401
+ // fall through: merged against the room's copy, ours goes out whole and signed like any edit
2402
+ }
2403
+ // Before anything below can publish this disk's content, merge it, or stamp it into the ledger:
2404
+ // a key on disk that the room does not have holds the whole file here (heldForSecret).
2405
+ {
2406
+ const shared0 = fe.text.toString()
2407
+ if (disk !== null && disk !== shared0) { if (heldForSecret(relPath, disk, shared0)) return }
2408
+ else if (withheld.has(relPath)) clearWithheld(relPath)
2409
+ }
1645
2410
  touchFile(relPath) // reconciling a file IS using it — keep it in the working set
1646
2411
  // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
1647
2412
  // this file, record any authored edit sitting on disk. The edit enters the provenance
@@ -1711,22 +2476,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1711
2476
  `priorShadow=${priorShadow.has(relPath)} inManifest=${manifest.has(relPath)} ` +
1712
2477
  `preexisting=${preexisting.has(relPath)}`)
1713
2478
  }
1714
- if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
2479
+ if (docText === '' && (!inManifest(relPath) || (disk && noHistory))) {
1715
2480
  // the relay has no usable copy of this path -> publish ours (a genuinely new file,
1716
2481
  // or one whose registration outlived the join that promised it)
1717
2482
  if (disk === null) return
1718
- if (process.env.HIVE_P14_DEBUG) log(`[P14] BRANCH=genesis-publish ${relPath}`)
1719
- fe.doc.transact(() => applyDiff(fe.text, disk))
2483
+ const now = Date.now()
2484
+ createdTimes.set(relPath, now)
2485
+ genesisPublished.add(relPath)
2486
+ fe.doc.transact(() => {
2487
+ applyDiff(fe.text, disk)
2488
+ if (fe.head && !fe.head.has('genesis')) fe.head.set('genesis', { at: now, by: name })
2489
+ })
1720
2490
  stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
1721
- manifest.set(relPath, 1)
2491
+ queueManifestSet(relPath)
1722
2492
  known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk); shadow.set(relPath, disk)
1723
2493
  try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
1724
- captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
1725
2494
  return
1726
2495
  }
1727
2496
  const yt = fe.text
1728
- if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
2497
+ if (disk === null) {
2498
+ if (!inManifest(relPath)) return
2499
+ writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return
2500
+ }
1729
2501
  if (disk === docText) {
2502
+ ensureExactDiskCasing(relPath)
1730
2503
  // CREATING a file is editing it. This branch is where a locally-authored NEW file
1731
2504
  // lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
1732
2505
  // runs disk and doc already agree -- so it returns below, before the markEditing() at
@@ -1743,7 +2516,6 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1743
2516
  // THIS author's fork on first sight or our own local activity (a remote
1744
2517
  // change landing on disk doesn't mean the local author has seen it).
1745
2518
  if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
1746
- if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
1747
2519
  return
1748
2520
  }
1749
2521
  // FIRST-CONTACT ADOPT (no common ancestor). We have never reconciled this file
@@ -1843,23 +2615,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1843
2615
  // rewrites of the same module and nobody was told. Now we run the same check:
1844
2616
  // if both inputs parsed but the merged result does not, or if the merge duplicated
1845
2617
  // structure, refuse it — keep both sides intact and announce loudly.
1846
- if (!r0.conflict) {
1847
- const why = fusionReason(r0.text, nl(disk), nl(docText), supports(relPath) ? languageFor(relPath) : null)
2618
+ //
2619
+ // A CONFLICTED race is refused the same way (27 Sep 2026). Two different whole files at one
2620
+ // point used to reach this check "clean": the old one-range merge3 treated two insertions
2621
+ // at the same place as disjoint and CONCATENATED them, and fusionReason caught the result.
2622
+ // The real diff3 (d487e0e) reports them as the conflict they are -- and the lines below
2623
+ // would then have landed both whole files, with markers, in the room's copy. HIV-P17's
2624
+ // answer to a creation race of CODE that cannot merge is to land nothing; that holds
2625
+ // either way. Same scope as the parse check: a file with no language (Markdown) is not
2626
+ // refused, and keeps both versions visibly, with markers, as any other conflict does.
2627
+ {
2628
+ const why = creationRaceVerdict(r0, disk, docText, relPath)
1848
2629
  if (why) {
1849
- conflicted.add(relPath)
1850
- // Do NOT write the fused result to doc or disk. The doc already holds docText
1851
- // (the room's copy) and disk holds the local draft — both are intact. Setting
1852
- // bases to docText means the next normal edit will merge against the room's copy,
1853
- // which is the same recovery path the normal-merge refusal uses.
1854
- bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
1855
- if (!manifest.has(relPath)) manifest.set(relPath, 1)
1856
- if (icrWarned.get(relPath) !== why) {
1857
- icrWarned.set(relPath, why)
1858
- log(`[${name}] !! HIV-P17 REFUSED a fused creation-race merge of ${relPath}: ${why}`)
1859
- try {
1860
- 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.`)
1861
- } catch { /* the log still carries it */ }
1862
- }
2630
+ refuseCreationRace(relPath, why, disk, docText)
1863
2631
  return
1864
2632
  }
1865
2633
  }
@@ -1867,7 +2635,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1867
2635
  if (r0.text !== disk) writeToDisk(relPath, r0.text)
1868
2636
  if (r0.conflict) conflicted.add(relPath)
1869
2637
  bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
1870
- if (!manifest.has(relPath)) manifest.set(relPath, 1)
2638
+ if (!inManifest(relPath)) queueManifestSet(relPath)
1871
2639
  try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
1872
2640
  return
1873
2641
  }
@@ -1875,14 +2643,37 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1875
2643
  // this room, we DO have a common ancestor and the adopt below is simply wrong: the
1876
2644
  // local copy may be newer than the room's (edited while disconnected, or the room
1877
2645
  // came back stale). This is the data-loss case that was seen live.
1878
- const anc = priorShadow.get(relPath)
2646
+ let anc = priorShadow.get(relPath)
2647
+ // ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
2648
+ // bytes BEFORE the relay accepts them. A write held behind someone else's claim
2649
+ // (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
2650
+ // disk === ancestor while the room still has the old copy, and adopting it deleted the edit
2651
+ // from the room AND from disk, silently (26 Oct). Signed receipts say whether the room ever
2652
+ // held these bytes; with none (provenance off) the old behaviour stands. When it never did,
2653
+ // the real ancestor is what the edit was made against, and the merge below re-sends it.
2654
+ let unsent = false
2655
+ const rcpts = fe.ledger ? fe.ledger.toArray() : []
2656
+ if (anc !== undefined && disk !== null && disk === anc &&
2657
+ accountFor({ rel: relPath, branchText: disk, roomText: docText, receipts: rcpts }).state === DIVERGED) {
2658
+ unsent = true
2659
+ // The newest base the room provably held. An older true ancestor merges safely; a newer
2660
+ // one the room never had (an earlier held edit) would read our own edit as the room
2661
+ // deleting it.
2662
+ const hist = priorFrom.get(relPath) || []
2663
+ let from
2664
+ for (let i = hist.length - 1; i >= 0 && from === undefined; i--) {
2665
+ const st = accountFor({ rel: relPath, branchText: hist[i], roomText: docText, receipts: rcpts }).state
2666
+ if (st === DESCENDS || st === IDENTICAL) from = hist[i]
2667
+ }
2668
+ if (from === undefined || from === disk) { keepUnsent(relPath, disk, docText); return }
2669
+ anc = from
2670
+ }
1879
2671
  if (anc !== undefined && disk !== null) {
1880
2672
  if (disk === anc) {
1881
2673
  // Local is exactly what we last wrote — nobody touched it while we were away, so
1882
2674
  // the room's copy is strictly newer and adopting it loses nothing.
1883
2675
  writeToDisk(relPath, docText)
1884
2676
  bases.set(relPath, docText); forkBases.set(relPath, docText)
1885
- if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' }); snappedBase.add(relPath) }
1886
2677
  return
1887
2678
  }
1888
2679
  // Divergent AND we know the ancestor: merge the two, do not pick a winner. Snapshot
@@ -1893,8 +2684,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1893
2684
  if (rj.text !== disk) writeToDisk(relPath, rj.text)
1894
2685
  if (rj.conflict) conflicted.add(relPath)
1895
2686
  bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
1896
- if (!manifest.has(relPath)) manifest.set(relPath, 1)
1897
- 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 { }
2687
+ if (!inManifest(relPath)) queueManifestSet(relPath)
2688
+ try {
2689
+ say(unsent
2690
+ ? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
2691
+ `It has been sent again, merged with the room's copy${rj.conflict ? ', and CONFLICT markers need resolving' : ''}; if the file is still claimed, it waits for the release. Pre-merge local copy is in the file's history.`
2692
+ : `⚠ ${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.`)
2693
+ } catch { }
1898
2694
  return
1899
2695
  }
1900
2696
  // Genuinely no ancestor (a clone-then-join, or joining with an existing checkout).
@@ -1908,8 +2704,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1908
2704
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
1909
2705
  writeToDisk(relPath, docText)
1910
2706
  bases.set(relPath, docText); forkBases.set(relPath, docText)
1911
- if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
1912
- 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 { }
2707
+ // Past the `disk === docText` return above, IDENTICAL can only mean the line endings differ.
2708
+ log(`[${name}] ↓ ${relPath} already existed in this room (${st.state}) — adopted the room's copy; the local version is in the file's history`)
2709
+ noteFirstContact(st.state === IDENTICAL ? 'eol' : 'adopted', relPath)
1913
2710
  return
1914
2711
  }
1915
2712
 
@@ -1919,11 +2716,70 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1919
2716
  // Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
1920
2717
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
1921
2718
  bases.set(relPath, disk); forkBases.set(relPath, disk)
1922
- try {
1923
- say(`⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted. Local disk was preserved. Resolve deliberately.`)
1924
- } catch { }
2719
+ log(`[${name}] ⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted; local disk preserved`)
2720
+ noteFirstContact('kept', relPath)
1925
2721
  return
1926
2722
  }
2723
+ // DEAD-HEAT GENESIS COLLISION DETECTION & RESOLUTION.
2724
+ // When two peers publish to an empty document at genesis concurrently before either
2725
+ // sees the other, both set bases to their own genesis text. When the remote update
2726
+ // lands, bases.has(relPath) is true on both peers, so neither enters the first-contact
2727
+ // creation race branch above. The CRDT unions both inserts. Normal diff3 merge against
2728
+ // base === disk accepts docText (the union), and fusionReason is blinded because 'theirs'
2729
+ // is already the invalid union. Both peers silently write the broken union to disk.
2730
+ //
2731
+ // Detection (bounded): only runs for files THIS client genesis-published this session,
2732
+ // on remote reconciles before anything writes to disk, and at most once per file.
2733
+ if (origin === 'remote' && genesisPublished.has(relPath) && !deadHeatResolved.has(relPath)) {
2734
+ const allGenesisClients = new Set()
2735
+ const liveGenesisClients = new Set()
2736
+ const liveTexts = new Map()
2737
+
2738
+ let it = fe.text._start
2739
+ while (it) {
2740
+ if (it.origin === null && it.rightOrigin === null) {
2741
+ allGenesisClients.add(it.id.client)
2742
+ if (!it.deleted) liveGenesisClients.add(it.id.client)
2743
+ }
2744
+ if (!it.deleted) {
2745
+ const c = it.id.client
2746
+ const s = typeof it.content?.str === 'string'
2747
+ ? it.content.str
2748
+ : (Array.isArray(it.content?.getContent?.()) ? it.content.getContent().join('') : '')
2749
+ liveTexts.set(c, (liveTexts.get(c) || '') + s)
2750
+ }
2751
+ it = it.right
2752
+ }
2753
+
2754
+ if (liveGenesisClients.size >= 2) {
2755
+ deadHeatResolved.add(relPath)
2756
+ const { winner: winnerClient, winnerText, why } =
2757
+ deadHeatVerdict(new Map([...liveGenesisClients].map((c) => [c, liveTexts.get(c) || ''])), relPath)
2758
+ // DISJOINT = LEAVE THE UNION ALONE. The CRDT union already holds every author's work.
2759
+ if (why) {
2760
+ // Conflicting or fused: the room keeps ONE whole file, the winner's; the losers keep theirs on disk.
2761
+ fe.doc.transact(() => applyDiff(fe.text, winnerText))
2762
+ if (fe.doc.clientID === winnerClient || disk === winnerText) {
2763
+ // The winner's disk already holds winnerText. Nothing to write, nothing to refuse, and not
2764
+ // marked conflicted: its next clean edit would otherwise announce a "resolved" conflict.
2765
+ bases.set(relPath, winnerText); forkBases.set(relPath, winnerText); shadow.set(relPath, winnerText)
2766
+ if (!inManifest(relPath)) queueManifestSet(relPath)
2767
+ log(`[${name}] HIV-P17 creation-race dead heat resolved: kept local genesis as winner for ${relPath}`)
2768
+ return
2769
+ }
2770
+ refuseCreationRace(relPath, why, disk, winnerText)
2771
+ return
2772
+ }
2773
+ } else if (allGenesisClients.size >= 2 && allGenesisClients.has(fe.doc.clientID) && disk !== docText) {
2774
+ // The winner resolved before this client reconciled: this client's genesis item is already deleted.
2775
+ const why = creationRaceVerdict(icrMerge3('', endNl(disk), endNl(docText), relPath), disk, docText, relPath)
2776
+ if (why) {
2777
+ deadHeatResolved.add(relPath)
2778
+ refuseCreationRace(relPath, why, disk, docText)
2779
+ return
2780
+ }
2781
+ }
2782
+ }
1927
2783
  const base = bases.has(relPath) ? bases.get(relPath) : disk
1928
2784
  const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
1929
2785
  if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
@@ -1952,6 +2808,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1952
2808
  } else {
1953
2809
  res = icrMerge3(fork, disk, docText, relPath)
1954
2810
  reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
2811
+ // OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
2812
+ // rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
2813
+ // tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
2814
+ // four texts, "B replaced A's change" and "B wrote from a copy that predates it" can be
2815
+ // identical. Markers lose nothing; a wrong guess does. The sound fix needs the agent to say
2816
+ // which version it edited (hive_edit carrying its base), so nothing here guesses.
1955
2817
  }
1956
2818
  } else {
1957
2819
  res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
@@ -1995,6 +2857,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1995
2857
  // Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
1996
2858
  // captured only on local origin so each change is recorded once, by its author.
1997
2859
  if (origin === 'local' && res.text !== docText) {
2860
+ let hasBase = false
2861
+ for (const e of fe.snap.values()) {
2862
+ if (e.kind === 'base') { hasBase = true; break }
2863
+ }
2864
+ if (!hasBase) {
2865
+ const createTs = fileCreationTs(fe, relPath)
2866
+ captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'created', ts: createTs })
2867
+ }
1998
2868
  const sc = summarizeChange(docText, res.text)
1999
2869
  captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
2000
2870
  lastLocalEdit = relPath
@@ -2006,6 +2876,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2006
2876
  // fusing. Recording (fork, disk) keeps every concurrent same-line edit anchored to the
2007
2877
  // SAME clean parent, so the fork stays detectable no matter how the char-CRDT fuses.
2008
2878
  if (origin === 'local') {
2879
+ if (res.text !== docText) bumpAttestVersion(relPath, res.text)
2009
2880
  // Gate ON: the author's INTENT was already captured at the source (captureLocal, top
2010
2881
  // of this pass) — here we only advance the authority head to the landed content.
2011
2882
  // Gate OFF (default): the proven landed-transition stamp, unchanged.
@@ -2054,7 +2925,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2054
2925
  detectFork(relPath)
2055
2926
  }
2056
2927
 
2057
- const fmtTime = () => new Date().toTimeString().slice(0, 8)
2928
+ const fmtTime = (d = Date.now()) => new Date(d).toTimeString().slice(0, 8)
2058
2929
  // Log file activity to the shared board: EVERY meaningful edit (so the live
2059
2930
  // activity feed reflects ongoing work), tagged with whether it was a full-file
2060
2931
  // `rewrite` and a numeric `ts` for ordering. HIVE_BOARD.md stays rewrites-only
@@ -2066,6 +2937,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2066
2937
  board.set(relPath, { by: name, at: fmtTime(), ts: Date.now(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols, rewrite: s.isRewrite })
2067
2938
  if (s.isRewrite) log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
2068
2939
  }
2940
+ let renderBoardTimer = null
2941
+ function renderBoardSoon() {
2942
+ if (stopped) return
2943
+ if (!renderBoardTimer) {
2944
+ renderBoardTimer = setImmediate(() => {
2945
+ renderBoardTimer = null
2946
+ renderBoard()
2947
+ linkMemory()
2948
+ })
2949
+ }
2950
+ }
2069
2951
  function renderBoard() {
2070
2952
  // HIVE_BOARD.md is the coordination doc: full-file REWRITES only (small edits
2071
2953
  // live in the activity feed via the board map, but don't clutter this file).
@@ -2079,7 +2961,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2079
2961
  for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
2080
2962
  writeToDisk(BOARD_FILE, out.join('\n') + '\n')
2081
2963
  }
2082
- if (syncFiles) board.observe(() => { renderBoard(); linkMemory() })
2964
+ if (syncFiles) board.observe(renderBoardSoon)
2083
2965
 
2084
2966
  // --- rollback: restore points + per-author revert + undo --------------------
2085
2967
  // Every meaningful change captures a RESTORE POINT: the file's content from JUST
@@ -2092,7 +2974,6 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2092
2974
  const SNAP_MIN_GAP_MS = 4000 // throttle auto-capture per file (fine-grained undo is the editor's job)
2093
2975
  const META_KEEP = 400 // global cap on the parent timeline index
2094
2976
  const lastSnapAt = new Map() // relPath -> ts of last auto-capture
2095
- const snappedBase = new Set() // files we've already floored with a baseline snapshot
2096
2977
  const snapId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
2097
2978
 
2098
2979
  function pruneMeta() {
@@ -2103,20 +2984,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2103
2984
  doc.transact(() => { for (let i = 0; i < Math.min(overflow, all.length); i++) historyMeta.delete(all[i].id) })
2104
2985
  }
2105
2986
 
2987
+ // Find file creation timestamp from createdTimes, genesis head, provenance ledger, head cur, mtimes, or disk stat.
2988
+ function fileCreationTs(fe, relPath) {
2989
+ if (createdTimes.has(relPath)) return createdTimes.get(relPath)
2990
+ try {
2991
+ const g = fe && fe.head ? fe.head.get('genesis') : null
2992
+ if (g && typeof g.at === 'number' && !isNaN(g.at)) return g.at
2993
+ } catch { }
2994
+ try {
2995
+ const l0 = fe && fe.ledger && fe.ledger.length > 0 ? fe.ledger.get(0) : null
2996
+ if (l0 && typeof l0.at === 'number' && !isNaN(l0.at)) return l0.at
2997
+ } catch { }
2998
+ try {
2999
+ const cur = fe && fe.head ? fe.head.get('cur') : null
3000
+ if (cur && typeof cur.at === 'number' && !isNaN(cur.at)) return cur.at
3001
+ } catch { }
3002
+ if (mtimes.has(relPath)) return mtimes.get(relPath)
3003
+ try {
3004
+ const full = path.join(ROOT, relPath)
3005
+ if (fs.existsSync(full)) return fs.statSync(full).mtimeMs
3006
+ } catch { }
3007
+ return Date.now()
3008
+ }
3009
+
2106
3010
  // Capture `content` as a restore point for relPath, attributed to `byWho`.
2107
3011
  // kind: 'auto' (an ordinary edit), 'base' (a file's floor), 'manual' (a named
2108
3012
  // checkpoint), 'restore' (the pre-restore state, kept so restore is reversible).
2109
- function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '' } = {}) {
3013
+ function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '', ts = null } = {}) {
2110
3014
  if (content == null) return null
2111
3015
  const fe = fileDocs.get(relPath); if (!fe) return null
2112
- const now = Date.now()
3016
+ const now = (typeof ts === 'number' && !isNaN(ts)) ? ts : Date.now()
2113
3017
  if (kind === 'auto' && !force && now - (lastSnapAt.get(relPath) || 0) < SNAP_MIN_GAP_MS) return null
2114
3018
  const snap = fe.snap
2115
3019
  let latest = null
2116
3020
  for (const e of snap.values()) if (!latest || (e.ts || 0) > (latest.ts || 0)) latest = e
2117
3021
  if (latest && latest.content === content) return null // identical to the most recent point — nothing to keep
2118
3022
  const id = snapId()
2119
- const at = fmtTime()
3023
+ const at = fmtTime(now)
2120
3024
  const pruned = []
2121
3025
  fe.doc.transact(() => {
2122
3026
  snap.set(id, { id, file: relPath, by: byWho, at, ts: now, content, churn, kind, label })
@@ -2124,17 +3028,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2124
3028
  for (let i = 0; i < autos.length - SNAP_KEEP; i++) { snap.delete(autos[i].id); pruned.push(autos[i].id) }
2125
3029
  })
2126
3030
  if (kind === 'auto') lastSnapAt.set(relPath, now)
2127
- doc.transact(() => {
2128
- historyMeta.set(id, { id, file: relPath, by: byWho, at, ts: now, churn, kind, label }) // metadata only — no content
2129
- for (const pid of pruned) historyMeta.delete(pid) // keep the index in step with evicted content
2130
- })
2131
- pruneMeta()
3031
+ pendingHistoryMeta.set(id, { op: 'set', record: { id, file: relPath, by: byWho, at, ts: now, churn, kind, label } })
3032
+ for (const pid of pruned) pendingHistoryMeta.set(pid, { op: 'delete' })
3033
+ scheduleRoomFlush()
2132
3034
  return id
2133
3035
  }
2134
3036
 
2135
3037
  // Find which file a restore-point id belongs to (via the index, else by scanning
2136
3038
  // open file docs as a fallback).
2137
3039
  function fileOfSnap(id) {
3040
+ flushRoomDoc()
2138
3041
  const m = historyMeta.get(id)
2139
3042
  if (m && m.file) return m.file
2140
3043
  for (const [rp, fe] of fileDocs) if (fe.snap.has(id)) return rp
@@ -2171,7 +3074,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2171
3074
  if (!fe) return { error: 'file not open' }
2172
3075
  let best = null
2173
3076
  for (const e of fe.snap.values()) if ((e.ts || 0) <= ts && (!best || (e.ts || 0) > (best.ts || 0))) best = e
2174
- if (!best) return { error: 'no restore point at/before that time' }
3077
+ if (!best) {
3078
+ if (fe.snap.size === 0) {
3079
+ const createTs = fileCreationTs(fe, relPath)
3080
+ if (typeof ts === 'number' && ts >= createTs) {
3081
+ return { ok: true, unchanged: true, file: relPath }
3082
+ }
3083
+ }
3084
+ return { error: 'no restore point at/before that time' }
3085
+ }
2175
3086
  return restore(best.id, by)
2176
3087
  }
2177
3088
 
@@ -2182,6 +3093,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2182
3093
  // For the dangerous case — an agent rewriting files — that's exactly right.
2183
3094
  function revertAuthor(who, sinceTs = 0, by = name) {
2184
3095
  if (!who) return { error: 'no author given' }
3096
+ flushRoomDoc()
2185
3097
  const earliest = new Map() // file -> earliest meta authored by `who` in the window
2186
3098
  for (const m of historyMeta.values()) {
2187
3099
  if (m.by !== who || (m.ts || 0) < sinceTs) continue
@@ -2197,6 +3109,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2197
3109
 
2198
3110
  // The timeline (newest first), read from the parent index. Optional filters.
2199
3111
  function listHistory({ file = null, by = null, limit = 200 } = {}) {
3112
+ flushRoomDoc()
2200
3113
  return [...historyMeta.values()]
2201
3114
  .filter((e) => (!file || e.file === file) && (!by || e.by === by))
2202
3115
  .sort((a, b) => (b.ts || 0) - (a.ts || 0))
@@ -2244,10 +3157,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2244
3157
  '# and announce what you are about to do (e.g. "taking auth.js: add login").',
2245
3158
  '',
2246
3159
  ]
2247
- for (const m of chat.toArray()) out.push(`- ${m.at} ${m.by} (${m.kind}): ${m.text}`)
3160
+ for (const m of chat.toArray()) out.push(`- ${formatTime(m.at)} ${m.by} (${m.kind}): ${m.text}`)
2248
3161
  writeToDisk(CHAT_FILE, out.join('\n') + '\n')
2249
3162
  }
2250
- // Hand the room's history to GRM whenever it changes, so this ROOM has a memory: an
3163
+ // Hand the room's history to the memory add-on, when present, whenever it changes, so this ROOM has a memory: an
2251
3164
  // agent joining tomorrow can ask what was decided here last week, including in
2252
3165
  // conversations it was never present for.
2253
3166
  //
@@ -2256,10 +3169,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2256
3169
  // compares before it writes, so calling it per message costs four stat() calls and no
2257
3170
  // disk write, and the relay watcher never sees needless churn.
2258
3171
  let memoryLinked = false
3172
+ let lastLinkedFilesCount = 0
2259
3173
  function linkMemory() {
2260
3174
  if (!syncFiles) return
3175
+ let count = 0
3176
+ for (const f of ['HIVE_CHAT.md', 'HIVE_BOARD.md', 'HIVE_TASKS.md', 'HIVE_MEMBERS.md']) {
3177
+ try { if (fs.existsSync(path.join(ROOT, f))) count++ } catch { }
3178
+ }
3179
+ if (count === 0 || (memoryLinked && count === lastLinkedFilesCount)) return
3180
+ if (!grmLink) return
2261
3181
  const r = grmLink.linkRoom(ROOT, memoryLinked ? () => {} : log) // announce once, not per message
2262
- if (r.linked) memoryLinked = true
3182
+ if (r.linked) {
3183
+ memoryLinked = true
3184
+ lastLinkedFilesCount = (r.files && r.files.length) || count
3185
+ }
2263
3186
  }
2264
3187
 
2265
3188
  if (syncFiles) chat.observe(() => { renderChat(); linkMemory() })
@@ -2289,17 +3212,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2289
3212
  else say(`@${to}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
2290
3213
  return id
2291
3214
  }
3215
+ // A decision is about a task that is WAITING for one. Approving an accepted or done task used to
3216
+ // rewrite it to 'accepted', which hands finished work straight back to the agent to do again.
3217
+ // 'denied' stays decidable: an owner may change their mind.
2292
3218
  function decide(id, accept, by = name) {
2293
3219
  const t = tasks.get(id)
2294
3220
  if (!t) return { error: 'no such task' }
2295
3221
  const ownerOf = owners.get(t.to)
2296
3222
  if (ownerOf && by !== ownerOf) return { error: `only ${ownerOf} (owner of ${t.to}) can approve task ${id}` }
3223
+ if (t.status !== 'pending' && t.status !== 'denied') return { error: `task ${id} is ${t.status}, not waiting for a decision` }
2297
3224
  tasks.set(id, { ...t, status: accept ? 'accepted' : 'denied', decidedBy: by })
2298
- say(`task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
3225
+ // An approval NAMES the agent (4 Oct 2026). An agent waiting between turns is woken by its
3226
+ // listener, and the listener wakes on @Name, so an approval that named nobody left the agent
3227
+ // asleep on work it had just been cleared to do. The same line is in control.html and the extension.
3228
+ say(`${accept ? `@${t.to} ` : ''}task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
2299
3229
  return { ok: true }
2300
3230
  }
3231
+ // Only the ASSIGNEE finishes a task, and only work it was cleared to do. Before 29 Sep 2026 anyone
3232
+ // could mark anyone's task done -- including one still PENDING its owner's approval, or DENIED --
3233
+ // so "done" could mean the owner said no and the room was told otherwise. Done twice is done.
2301
3234
  function complete(id, note = '') {
2302
3235
  const t = tasks.get(id); if (!t) return { error: 'no such task' }
3236
+ if (t.to !== name) return { error: `task ${id} is ${t.to}'s -- only ${t.to} can complete it` }
3237
+ if (t.status === 'done') return { ok: true, already: true }
3238
+ if (t.status !== 'accepted') return { error: `task ${id} is ${t.status}: only accepted work can be completed${t.status === 'pending' ? ` (it is waiting for ${owners.get(t.to) || 'its owner'} to approve)` : ''}` }
2303
3239
  tasks.set(id, { ...t, status: 'done', decidedBy: t.decidedBy })
2304
3240
  say(`task ${id} done by ${name}${note ? ': ' + note : ''}`)
2305
3241
  return { ok: true }
@@ -2337,7 +3273,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2337
3273
  let myEditing = null, myLastEdit = null
2338
3274
  let editingClearTimer = null
2339
3275
  const warnedAt = new Map() // file -> last time we warned about co-editing it
2340
- const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, passive: !!passive })
3276
+ const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID })
2341
3277
  function markEditing(relPath) {
2342
3278
  myEditing = { file: relPath, at: Date.now() }
2343
3279
  myLastEdit = myEditing
@@ -2436,6 +3372,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2436
3372
  log(`[${name}] ⚠ ${u.name} is on ${theirs} (incompatible with v${PROTOCOL_VERSION}) — they won't sync correctly until everyone updates.`)
2437
3373
  try { say(`⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`) } catch { }
2438
3374
  }
3375
+ checkBuilds()
3376
+ }
3377
+ // MIXED BUILDS, SEEN BY THE ROOM ITSELF (29 Sep 2026). The same protocol does not mean the same
3378
+ // engine: two builds that merge differently corrupt each other's files with every write green.
3379
+ // Until now the only detector was each agent remembering to say "joined on hivecode X". Every
3380
+ // engine client now carries its build in presence (hv), so the room can see a mismatch without
3381
+ // anyone announcing it. Rules, each against a false alarm:
3382
+ // - only clients that report a build are compared, EXCEPT an AI on this protocol reporting none:
3383
+ // that is an engine older than build reporting, the rollout case, and it cannot warn itself
3384
+ // - humans are not flagged: the extension and the browser panel carry no engine build
3385
+ // - one side of each pair speaks (the name that sorts first), once per build it sees
3386
+ const buildWarned = new Set()
3387
+ function checkBuilds() {
3388
+ // An old engine cannot speak for itself, so SOMEBODY current must -- but only one: the reporting
3389
+ // client whose name sorts first. Otherwise every current client in the room says the same thing.
3390
+ const reporters = [name]
3391
+ for (const s of provider.awareness.getStates().values()) { const u = s && s.user; if (u && u.name && u.hv) reporters.push(u.name) }
3392
+ const speakForOld = reporters.sort()[0] === name
3393
+ for (const s of provider.awareness.getStates().values()) {
3394
+ const u = s && s.user
3395
+ if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION) continue
3396
+ const human = String(u.kind || '').toLowerCase() === 'human'
3397
+ let theirs = ''
3398
+ if (u.hv) {
3399
+ if (u.hv === VERSION_LABEL || name > u.name) continue
3400
+ theirs = `hivecode ${u.hv}`
3401
+ } else if (!human) {
3402
+ if (!speakForOld) continue
3403
+ theirs = `a hivecode build older than build reporting (before ${VERSION})`
3404
+ } else continue
3405
+ const key = `${u.name}|${u.hv || '-'}`
3406
+ if (buildWarned.has(key)) continue
3407
+ buildWarned.add(key)
3408
+ const same = u.hv && u.hv.split('+')[0] === VERSION ? ' (the same release from different commits)' : ''
3409
+ log(`[${name}] ⚠ MIXED BUILDS: ${u.name} runs ${theirs}, this client runs ${VERSION_LABEL}${same}`)
3410
+ try { say(`⚠ MIXED BUILDS: ${u.name} runs ${theirs}, ${name} runs hivecode ${VERSION_LABEL}${same}. Two builds in one room can lose file content with no error on either side -- everyone should run the same build.`) } catch { }
3411
+ }
2439
3412
  }
2440
3413
  if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
2441
3414
 
@@ -2469,6 +3442,65 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2469
3442
  // scroll past — but deleting the case entirely is worse than announcing it cheaply.
2470
3443
  const ENGINE_ID = 'hivecode'
2471
3444
  const invisibleWarned = new Map() // relPath -> obligation id, or null for the ambient case
3445
+ const unsharedWarned = new Map() // relPath -> { id: obligation id | null, reason: 'binary' | 'oversized', size: number }
3446
+ const unsharedMtimes = new Map() // relPath -> mtimeMs (for CPU skip in scan)
3447
+
3448
+ function noticeUnshared(currentUnshared) {
3449
+ for (const r of [...unsharedWarned.keys()]) if (!currentUnshared.has(r)) dischargeUnshared(r)
3450
+ for (const [r, info] of currentUnshared) noticeUnsharedOne(r, info)
3451
+ }
3452
+
3453
+ function dischargeUnshared(r) {
3454
+ if (!unsharedWarned.has(r) && !unsharedMtimes.has(r)) return
3455
+ const entry = unsharedWarned.get(r)
3456
+ if (entry && entry.id) {
3457
+ try { OB.discharge(doc.getMap('obligations'), { id: entry.id, by: ENGINE_ID }) } catch { }
3458
+ }
3459
+ unsharedWarned.delete(r)
3460
+ unsharedMtimes.delete(r)
3461
+ }
3462
+
3463
+ function noticeUnsharedOne(r, info) {
3464
+ if (SKIP.has(r) || OWN_LOCAL.has(r)) return
3465
+ const holder = coordinator.sense(r)
3466
+ const existing = unsharedWarned.get(r)
3467
+ if (existing && (existing.id || !holder || !holder.by)) return
3468
+
3469
+ const reasonText = info.reason === 'oversized'
3470
+ ? `oversized (${Math.round(info.size / 1024)}KB > 1MB limit)`
3471
+ : 'binary (contains null bytes)'
3472
+ const inRoom = inManifest(r)
3473
+ const contextPrefix = inRoom
3474
+ ? `${r} is in the room as text, but local file is ${reasonText}`
3475
+ : `${r} is ${reasonText}`
3476
+
3477
+ if (holder && holder.by) {
3478
+ const guidance = `stays on ${holder.by}'s machine only: teammates will not get it from the room. Share it another way (commit it).`
3479
+ let id = null
3480
+ try {
3481
+ const rec = OB.open(doc.getMap('obligations'), {
3482
+ from: ENGINE_ID,
3483
+ to: holder.by,
3484
+ text: `⛔ ${r} is on disk and claimed by you, but cannot be shared because it is ${reasonText} — it ${guidance}`,
3485
+ absent: false,
3486
+ neverSeen: false,
3487
+ })
3488
+ id = rec && rec.id
3489
+ } catch { }
3490
+ unsharedWarned.set(r, { id, reason: info.reason, size: info.size })
3491
+ try {
3492
+ say(`⛔ ${contextPrefix} and claimed by ${holder.by} — it ${guidance}`)
3493
+ } catch { }
3494
+ } else {
3495
+ const guidance = `stays on ${name}'s machine only: teammates will not get it from the room. Share it another way (commit it).`
3496
+ unsharedWarned.set(r, { id: null, reason: info.reason, size: info.size })
3497
+ if (!preexisting.has(r) || inRoom) {
3498
+ try {
3499
+ say(`· ${contextPrefix} — it ${guidance}`)
3500
+ } catch { }
3501
+ }
3502
+ }
3503
+ }
2472
3504
 
2473
3505
  function noticeBornInvisible(ignoredRel) {
2474
3506
  // DISCHARGE FIRST. A file that has gone away takes its debt with it: the question "this will
@@ -2476,44 +3508,190 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2476
3508
  // fills with debts about paths that no longer exist, which is the noise the three-state
2477
3509
  // lifecycle exists to prevent. Its expiry cannot help here — that only covers names never seen
2478
3510
  // in the room, and this path was seen, on disk, by us.
2479
- for (const [r, id] of [...invisibleWarned]) {
2480
- if (ignoredRel.has(r)) continue
2481
- if (id) { try { OB.discharge(doc.getMap('obligations'), { id, by: ENGINE_ID }) } catch { } }
2482
- invisibleWarned.delete(r)
2483
- }
2484
- for (const r of ignoredRel) {
2485
- if (SKIP.has(r) || OWN_LOCAL.has(r)) continue // control files are ours and are supposed to be invisible
2486
- if (preexisting.has(r)) continue // it was already here; nobody did this during the session
2487
- if (invisibleWarned.has(r)) continue // once per path, not once per scan tick
2488
- const holder = coordinator.sense(r)
2489
- if (holder && holder.by) {
2490
- let id = null
2491
- try {
2492
- const rec = OB.open(doc.getMap('obligations'), {
2493
- from: ENGINE_ID,
2494
- to: holder.by,
2495
- text: `${r} is on disk and claimed by you, but it is ignored by a .gitignore here — ` +
2496
- `no sync connection is opened for it, so nothing written to it will ever reach the room. ` +
2497
- `Add it to the allow-list, or confirm the work is deliberately local.`,
2498
- absent: false,
2499
- neverSeen: false,
2500
- })
2501
- id = rec && rec.id
2502
- } catch { }
2503
- invisibleWarned.set(r, id)
2504
- try {
2505
- say(`⛔ ${r} is claimed and on disk, but it is ignored by a .gitignore here — its bytes will never leave this folder.`)
2506
- } catch { }
2507
- } else {
2508
- invisibleWarned.set(r, null)
2509
- try {
2510
- say(`· ${r} was created here but is ignored by a .gitignore — it stays local, and no other agent will see it.`)
2511
- } catch { }
3511
+ for (const r of [...invisibleWarned.keys()]) if (!ignoredRel.has(r)) dischargeInvisible(r)
3512
+ for (const r of ignoredRel) noticeInvisibleOne(r)
3513
+ }
3514
+ function dischargeInvisible(r) {
3515
+ if (!invisibleWarned.has(r)) return
3516
+ const id = invisibleWarned.get(r)
3517
+ if (id) { try { OB.discharge(doc.getMap('obligations'), { id, by: ENGINE_ID }) } catch { } }
3518
+ invisibleWarned.delete(r)
3519
+ }
3520
+ // Would walk() list this path as an ignored FILE -- the only kind noticeBornInvisible speaks
3521
+ // about? Not when a directory above it is pruned, as node_modules/ and .git/ are: an npm install
3522
+ // must not become ten thousand chat lines because a watcher event named each file.
3523
+ function walkSkipsAsIgnoredFile(r) {
3524
+ const segs = r.split('/')
3525
+ for (let i = 0; i < segs.length - 1; i++) {
3526
+ if (IGNORE.has(segs[i]) || isIgnored(segs.slice(0, i + 1).join('/') + '/')) return false
3527
+ }
3528
+ return !IGNORE.has(segs[segs.length - 1]) && isIgnored(r)
3529
+ }
3530
+ function noticeInvisibleOne(r) {
3531
+ if (SKIP.has(r) || OWN_LOCAL.has(r)) return // control files are ours and are supposed to be invisible
3532
+ if (preexisting.has(r)) return // it was already here; nobody did this during the session
3533
+ if (invisibleWarned.has(r)) return // once per path, not once per scan tick
3534
+ const holder = coordinator.sense(r)
3535
+ if (holder && holder.by) {
3536
+ let id = null
3537
+ try {
3538
+ const rec = OB.open(doc.getMap('obligations'), {
3539
+ from: ENGINE_ID,
3540
+ to: holder.by,
3541
+ text: `${r} is on disk and claimed by you, but it is ignored by a .gitignore here — ` +
3542
+ `no sync connection is opened for it, so nothing written to it will ever reach the room. ` +
3543
+ `Add it to the allow-list, or confirm the work is deliberately local.`,
3544
+ absent: false,
3545
+ neverSeen: false,
3546
+ })
3547
+ id = rec && rec.id
3548
+ } catch { }
3549
+ invisibleWarned.set(r, id)
3550
+ try {
3551
+ say(`⛔ ${r} is claimed and on disk, but it is ignored by a .gitignore here — its bytes will never leave this folder.`)
3552
+ } catch { }
3553
+ } else {
3554
+ invisibleWarned.set(r, null)
3555
+ try {
3556
+ say(`· ${r} was created here but is ignored by a .gitignore — it stays local, and no other agent will see it.`)
3557
+ } catch { }
3558
+ }
3559
+ }
3560
+
3561
+ // ---- deletes: a missing folder is not a deletion, and a delete from the room keeps a copy ----
3562
+ //
3563
+ // Measured 3 Oct 2026 on a local relay, before any of this existed: one agent's `rm -rf src`
3564
+ // took 30 files out of its teammate's folder within seconds, and nothing kept them -- a delete
3565
+ // from the room was a bare rmSync. Deleting the agent's WHOLE room folder was worse: on Windows
3566
+ // fs.watch threw EPERM with no handler and the agent's hivecode server died, and wherever the
3567
+ // watcher survives, the next scan walked a folder that was not there, read every file as
3568
+ // deleted, and emptied the room for everyone (12 of 12 files gone from the teammate's folder).
3569
+ function rootIsDir() { try { return fs.statSync(ROOT).isDirectory() } catch { return false } }
3570
+
3571
+ // LOCAL DELETES SETTLE, THEN LEAVE AS ONE BATCH. `rm -rf` removes the files first and the folder
3572
+ // last, and each removal used to reach the room 40 ms after its watcher event -- so on a big
3573
+ // folder most of the deletes were already out while the folder still existed, and no check of
3574
+ // the folder could catch it. Nobody needs a delete in 40 ms. Waiting for the deleting to stop
3575
+ // lets the batch be judged whole: the folder gone, or every file in it gone at once, reads as a
3576
+ // lost folder (loseFolder), never as N deliberate deletes. It also stops an editor that saves by
3577
+ // delete-then-write from sending a delete and a re-create of the same file.
3578
+ const pendingLocalDeletes = new Set()
3579
+ let deleteTimer = null
3580
+ function noteLocalDelete(r) {
3581
+ if (pendingLocalDeletes.has(r)) return // a rescan of the same absence must not keep postponing it
3582
+ pendingLocalDeletes.add(r)
3583
+ if (deleteTimer) clearTimeout(deleteTimer)
3584
+ deleteTimer = setTimeout(settleLocalDeletes, DELETE_SETTLE_MS)
3585
+ }
3586
+ function settleLocalDeletes() {
3587
+ if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null }
3588
+ if (!pendingLocalDeletes.size) return
3589
+ const pending = [...pendingLocalDeletes]
3590
+ pendingLocalDeletes.clear()
3591
+ if (folderLost) return
3592
+ if (!rootIsDir()) { loseFolder('is gone'); return }
3593
+ // Gone now = still absent, or ignored since (walk() skips both, and both always retracted the
3594
+ // file from the room). Anything that came back during the wait is an edit, not a delete.
3595
+ const fileExistsExact = (r) => (!isCaseInsensitive ? fs.existsSync(path.join(ROOT, r)) : actualDiskPath(r) === r)
3596
+ const gone = pending.filter((r) => known.has(r) && inManifest(r) && (!fileExistsExact(r) || isIgnored(r)))
3597
+ if (!gone.length) return
3598
+ const held = [...known].filter((r) => inManifest(r)).length
3599
+ if (held >= 5 && gone.length >= held) { loseFolder(`was emptied: all ${held} of its files vanished at once`); return }
3600
+ // A FILE SOMEONE ELSE HOLDS IS NOT DELETED UNDER THEM. Measured 3 Oct 2026, with claims enforced
3601
+ // as queue and as block alike: Bob claimed a.js and was editing it, Alice deleted it, and it left
3602
+ // the room and Bob's folder -- a claim stopped every write except the largest one. Such a delete is
3603
+ // not sent; the file goes back into this folder from the room, so the folder matches the room
3604
+ // and the next scan does not try again; and the room is told why, once.
3605
+ const refused = []
3606
+ const send = []
3607
+ for (const r of gone) {
3608
+ const h = coordinator.sense(r)
3609
+ if (h && h.by && h.by !== name) refused.push([r, h.by]); else send.push(r)
3610
+ }
3611
+ for (const [r] of refused) {
3612
+ if (fileExistsExact(r)) continue // ignored since, not absent: nothing to put back
3613
+ const e = fileDocs.get(r)
3614
+ if (e) writeToDisk(r, e.text.toString())
3615
+ else { known.delete(r); mtimes.delete(r); queueOpenFile(r) } // reopened, it arrives like any room file
3616
+ }
3617
+ if (refused.length) {
3618
+ const what = refused.slice(0, 3).map(([r, by]) => `${r} (held by ${by})`).join(', ') + (refused.length > 3 ? ` (+${refused.length - 3} more)` : '')
3619
+ log(`[${name}] delete not sent, the file is held by someone else: ${what}`)
3620
+ try { say(`⛔ ${name} deleted ${what}, but a file someone else holds is not deleted under them, so that delete was not sent and the file is back in ${name}'s folder. Ask the holder to release it first.`) } catch { }
3621
+ }
3622
+ if (!send.length) return
3623
+ for (const r of send) queueManifestDelete(r)
3624
+ flushRoomDoc()
3625
+ for (const r of send) {
3626
+ closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
3627
+ if (isCaseInsensitive) {
3628
+ const cf = fold(r)
3629
+ collided.delete(r)
3630
+ collidedWarned.delete(r)
3631
+ clearCollidedTimer(r)
3632
+ if (canonicalCaseKeys.get(cf) === r) {
3633
+ canonicalCaseKeys.delete(cf)
3634
+ for (const [colKey, info] of collided.entries()) {
3635
+ if (info.with === r && inManifest(colKey)) {
3636
+ collided.delete(colKey)
3637
+ collidedWarned.delete(colKey)
3638
+ clearCollidedTimer(colKey)
3639
+ canonicalCaseKeys.set(cf, colKey)
3640
+ break
3641
+ }
3642
+ }
3643
+ }
2512
3644
  }
2513
3645
  }
3646
+ const list = send.slice(0, 3).join(', ') + (send.length > 3 ? ` (+${send.length - 3} more)` : '')
3647
+ log(`[${name}] -> deleted for everyone: ${list}`)
3648
+ if (send.length >= BULK_DELETE) {
3649
+ try { say(`· ${name} deleted ${send.length} files for everyone: ${list}. Teammates' folders keep a copy of what they held, in ${DELETED_DIR}/, for 7 days.`) } catch { }
3650
+ }
2514
3651
  }
2515
3652
 
3653
+ // A DELETE FROM THE ROOM KEEPS A COPY. The file moves to .hive-deleted/<when>/<path> in this
3654
+ // folder (ALWAYS_IGNORE, so it never syncs back). If the move fails -- an editor holding the file
3655
+ // on Windows -- it is copied, then removed. If even the copy fails it is still removed, as
3656
+ // before: a file left in place would be read as new by the next scan and resurrected for everyone.
3657
+ function deletedStamp() { return new Date().toISOString().replace(/[:.]/g, '-') }
3658
+ function keepDeletedCopy(relPath, stamp) {
3659
+ if (!isSafeRelPath(relPath)) return null
3660
+ const from = path.join(ROOT, relPath)
3661
+ let st
3662
+ try { st = fs.statSync(from) } catch { return null } // never reached this disk: nothing to keep
3663
+ if (!st.isFile()) return null
3664
+ const keptAs = `${DELETED_DIR}/${stamp}/${relPath}`
3665
+ const to = path.join(ROOT, keptAs)
3666
+ try { fs.mkdirSync(path.dirname(to), { recursive: true }) } catch { }
3667
+ try { fs.renameSync(from, to); return keptAs } catch { }
3668
+ let copied = false
3669
+ try { fs.copyFileSync(from, to); copied = true } catch { }
3670
+ try { fs.rmSync(from) } catch { }
3671
+ return copied ? keptAs : null
3672
+ }
3673
+ // Only folders this code named (an ISO stamp) and only once they are a week old: anything else a
3674
+ // person put in .hive-deleted/ is theirs.
3675
+ function pruneDeletedCopies() {
3676
+ const base = path.join(ROOT, DELETED_DIR)
3677
+ let names = []
3678
+ try { names = fs.readdirSync(base) } catch { return }
3679
+ const cutoff = Date.now() - DELETED_KEEP_MS
3680
+ for (const n of names) {
3681
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(n)
3682
+ if (!m) continue
3683
+ const t = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`)
3684
+ if (t < cutoff) { try { fs.rmSync(path.join(base, n), { recursive: true, force: true }) } catch { } }
3685
+ }
3686
+ }
3687
+
3688
+ let scanCount = 0
2516
3689
  function scan() {
3690
+ scanCount++
3691
+ if (folderLost) return 0
3692
+ // walk() of a missing folder returns nothing, which used to read as "every file was deleted".
3693
+ if (!rootIsDir()) { loseFolder('is gone'); return 0 }
3694
+ if (unwritable.size) retryUnwritable()
2517
3695
  const skippedFulls = []
2518
3696
  const diskFulls = walk(ROOT, [], skippedFulls)
2519
3697
  const diskRel = new Set(diskFulls.map(rel))
@@ -2539,61 +3717,399 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2539
3717
  firstScanDone = true
2540
3718
  }
2541
3719
  noticeBornInvisible(ignoredRel)
3720
+ const currentUnshared = new Map()
3721
+ let changes = 0
2542
3722
  for (const full of diskFulls) {
2543
3723
  const r = rel(full)
2544
3724
  if (SKIP.has(r)) continue
3725
+ checkCaseCollision(r)
3726
+ if (collided.has(r)) continue
2545
3727
  let mt
2546
3728
  try { mt = fs.statSync(full).mtimeMs } catch { continue }
2547
- if (mtimes.get(r) === mt && manifest.has(r)) continue
2548
- if (readText(full) === null) continue
3729
+
3730
+ // 1. If previously known unshared and mtime hasn't changed, skip re-reading buffer!
3731
+ if (unsharedMtimes.get(r) === mt && unsharedWarned.has(r)) {
3732
+ currentUnshared.set(r, unsharedWarned.get(r))
3733
+ continue
3734
+ }
3735
+
3736
+ // 2. If known shareable and mtime hasn't changed, skip!
3737
+ if (mtimes.get(r) === mt && inManifest(r)) continue
3738
+
3739
+ // 3. mtime changed or new file: check unshared
3740
+ const unshared = checkUnshared(full)
3741
+ if (unshared) {
3742
+ unsharedMtimes.set(r, mt)
3743
+ currentUnshared.set(r, unshared)
3744
+ continue
3745
+ } else {
3746
+ unsharedMtimes.delete(r)
3747
+ }
3748
+
3749
+ if (readText(full) === null) {
3750
+ if (isBinaryFile(full)) {
3751
+ mtimes.set(r, mt)
3752
+ reconcileBinary(r, 'local')
3753
+ changes++
3754
+ continue
3755
+ }
3756
+ continue
3757
+ }
2549
3758
  mtimes.set(r, mt)
2550
3759
  reconcile(r, 'local')
3760
+ changes++
2551
3761
  }
2552
- const removed = [...known].filter((r) => !diskRel.has(r) && manifest.has(r))
3762
+ noticeUnshared(currentUnshared)
3763
+ const removed = [...known].filter((r) => !diskRel.has(r) && inManifest(r) && !pendingLocalDeletes.has(r) && !collided.has(r))
2553
3764
  if (removed.length) {
2554
- doc.transact(() => { for (const r of removed) manifest.delete(r) })
2555
- for (const r of removed) { closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r) }
3765
+ changes += removed.length
3766
+ for (const r of removed) {
3767
+ dischargeUnshared(r)
3768
+ noteLocalDelete(r) // leaves with its batch; see settleLocalDeletes
3769
+ }
3770
+ }
3771
+ return changes
3772
+ }
3773
+
3774
+ // Non-blocking chunked file opening:
3775
+ // When an existing room doc syncs on join, 150-900 files arrive in manifest at once.
3776
+ // Opening them all synchronously inside Y.applyUpdate froze the Node event loop for
3777
+ // 5-8+ seconds, blowing past hive_join's 8s sync timeout and causing false join failures.
3778
+ // Chunking opens via setImmediate drains the batch smoothly in 32-file slices, firing
3779
+ // provider.on('sync') within milliseconds while keeping single-file live edits instant.
3780
+ const openQueue = []
3781
+ const openQueueSet = new Set()
3782
+ let openTimer = null
3783
+ // Test-only: hold the first drain this long, so a test can land a peer's delete while a key waits.
3784
+ const OPEN_DELAY_MS = Number(process.env.HIVE_TEST_OPEN_DELAY_MS) || 0
3785
+ let openTurns = 0 // drains run; tests/join-scale-test.mjs pins how few a big join takes
3786
+ function drainOpenQueue() {
3787
+ openTimer = null
3788
+ openTurns++
3789
+ // 256 A TURN ON ONE SOCKET, 32 ON A SOCKET PER FILE (27 Sep 2026). Each turn boundary waits
3790
+ // behind whatever else the loop runs in between, and during a big join that is a lot: measured
3791
+ // on a 1,000-file room arriving in one manifest event, every 32-file turn ran 1.1-1.4 s after
3792
+ // the last, so the joiner took ~49 s to have the files on disk; 256 a turn, 25 s. On the mux
3793
+ // transport an OPEN is one frame on a socket that already exists; on per-file sockets each is
3794
+ // a new connection, so that path keeps 32. The 100 ms ceiling keeps a slow machine from
3795
+ // holding the loop through a turn -- past the first 32, a turn stops when it runs out of time.
3796
+ const BATCH = fileTransport.mode === 'mux' ? 256 : 32
3797
+ const t0 = Date.now()
3798
+ let openedCount = 0
3799
+ while (openQueue.length && openedCount < BATCH && !stopped && (openedCount < 32 || Date.now() - t0 < 100)) {
3800
+ const key = openQueue.shift()
3801
+ openQueueSet.delete(key)
3802
+ if (fileDocs.size >= maxOpen()) {
3803
+ openQueue.length = 0
3804
+ openQueueSet.clear()
3805
+ break
3806
+ }
3807
+ // STILL WANTED? A key can wait here while a peer deletes the file. Its document still exists on
3808
+ // the relay, so opening it would bring the deleted file back to this disk and publish it again
3809
+ // for everyone. Wanted = still in the room, or on this disk (a new local file not yet published).
3810
+ const wanted = inManifest(key) || fs.existsSync(path.join(ROOT, key))
3811
+ if (wanted && canOpen(key) && !SKIP.has(key) && !isIgnored(key) && !fileDocs.has(key)) {
3812
+ openFile(key)
3813
+ openedCount++
3814
+ }
3815
+ }
3816
+ if (openQueue.length && !stopped && fileDocs.size < maxOpen()) {
3817
+ openTimer = setImmediate(drainOpenQueue)
3818
+ }
3819
+ }
3820
+
3821
+ function queueOpenFile(key) {
3822
+ if (fileDocs.has(key) || fileDocs.size >= maxOpen() || openQueueSet.has(key)) return
3823
+ openQueueSet.add(key)
3824
+ if (fs.existsSync(path.join(ROOT, key))) openQueue.unshift(key)
3825
+ else openQueue.push(key)
3826
+ if (!openTimer && !stopped) {
3827
+ openTimer = OPEN_DELAY_MS ? setTimeout(drainOpenQueue, OPEN_DELAY_MS) : setImmediate(drainOpenQueue)
2556
3828
  }
2557
3829
  }
2558
3830
 
2559
3831
  // Manifest changes from OTHERS: a new path -> open its file-room (its sync pulls
2560
- // the content); a removed path -> tear down + delete the local file.
3832
+ // the content); a removed path -> tear down, and move the local file into .hive-deleted/.
2561
3833
  if (syncFiles) manifest.observe((ev, txn) => {
2562
3834
  if (txn.local) return
3835
+ if (folderLost) return // nothing in a lost folder is written, moved or recreated
3836
+ let stamp = null
3837
+ let kept = 0
3838
+ // Process deletions BEFORE additions so that renames (delete old + add new)
3839
+ // retire the old path and free canonicalCaseKeys before the new casing is checked.
3840
+ const deletes = []
3841
+ const others = []
2563
3842
  ev.changes.keys.forEach((change, key) => {
2564
- if (change.action === 'delete') {
2565
- closeFile(key)
2566
- try { fs.rmSync(path.join(ROOT, key)) } catch { }
2567
- known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key)
2568
- log(`[${name}] <- deleted ${key}`)
2569
- } else if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
2570
- // Same rule as the initial sweep: a path this client ignores must not cost it a
2571
- // socket just because somebody else registered it.
2572
- //
2573
- // AND the same BOUND. This observer is how a joining client learns the room's whole
2574
- // manifest — every existing path arrives here as a remote change — so capping only
2575
- // the sweep achieved nothing: the first measurement of this cap showed a client with
2576
- // cap 8 holding 30 file docs, every one of them opened right here. A path we decline
2577
- // to open now is not lost; it opens when something touches it.
2578
- if (fileDocs.size < maxOpen()) openFile(key)
2579
- }
3843
+ if (change.action === 'delete') deletes.push([key, change])
3844
+ else others.push([key, change])
2580
3845
  })
3846
+
3847
+ for (const [key, change] of deletes) {
3848
+ let promotedKey = null
3849
+ if (isCaseInsensitive) {
3850
+ const cf = fold(key)
3851
+ clearCollidedTimer(key)
3852
+ if (collided.has(key)) {
3853
+ collided.delete(key)
3854
+ collidedWarned.delete(key)
3855
+ } else if (canonicalCaseKeys.get(cf) === key) {
3856
+ canonicalCaseKeys.delete(cf)
3857
+ for (const [colKey, info] of collided.entries()) {
3858
+ if (info.with === key && inManifest(colKey)) {
3859
+ collided.delete(colKey)
3860
+ collidedWarned.delete(colKey)
3861
+ clearCollidedTimer(colKey)
3862
+ canonicalCaseKeys.set(cf, colKey)
3863
+ promotedKey = colKey
3864
+ break
3865
+ }
3866
+ }
3867
+ }
3868
+ }
3869
+ pendingManifestOps.delete(key)
3870
+ closeFile(key)
3871
+ { const qi = openQueue.indexOf(key); if (qi >= 0) { openQueue.splice(qi, 1); openQueueSet.delete(key) } } // deleted while queued
3872
+ if (!stamp) stamp = deletedStamp() // one folder per batch, so a bulk delete comes back together
3873
+ const keptAs = keepDeletedCopy(key, stamp)
3874
+ if (keptAs) kept++
3875
+ known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key); shadow.delete(key); shaShadow.delete(key); unwritable.delete(key)
3876
+ log(`[${name}] <- deleted ${key}${keptAs ? ` (copy kept: ${keptAs})` : ''}`)
3877
+ if (promotedKey) {
3878
+ reconcile(promotedKey, 'remote')
3879
+ }
3880
+ }
3881
+
3882
+ for (const [key, change] of others) {
3883
+ if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
3884
+ checkCaseCollision(key)
3885
+ if (ev.changes.keys.size <= 2 && fileDocs.size < maxOpen()) {
3886
+ openFile(key)
3887
+ } else {
3888
+ queueOpenFile(key)
3889
+ }
3890
+ }
3891
+ }
3892
+ if (kept > 1) log(`[${name}] <- ${kept} files deleted by a teammate; copies kept in ${DELETED_DIR}/${stamp}/`)
2581
3893
  })
2582
3894
 
2583
3895
  // INSTANT propagation: fs.watch fires on the actual edit, so changes go out in
2584
3896
  // ~tens of ms instead of waiting for the poll. A periodic scan stays as a
2585
3897
  // safety net (and the only mechanism where recursive watch isn't supported).
3898
+ //
3899
+ // ADAPTIVE SAFETY NET:
3900
+ // When fs.watch is healthy, scanning untouched files every 2s wastes ~37ms of CPU
3901
+ // on a 900-file folder (1,108ms/min). If a scan detects ZERO changes that fs.watch
3902
+ // hadn't already reconciled, we double the interval: 2s -> 4s -> 8s -> 16s.
3903
+ // The instant a scan catches an unobserved disk change (e.g. editor atomic save
3904
+ // or network drive missing watcher events), interval snaps back to 2s immediately.
3905
+ // When recursive watch is unsupported (!watcher), interval stays fixed at 400ms.
3906
+ const SCAN_MIN_MS = 2000
3907
+ const SCAN_MAX_MS = 16000
3908
+ const SCAN_NO_WATCHER_MS = 400
3909
+ let scanInterval = SCAN_MIN_MS
2586
3910
  let scanTimer = null
2587
3911
  let watcher = null
2588
3912
  let debounce = null
2589
- const onFsEvent = (_evt, fname) => {
3913
+
3914
+ function scheduleNextScan(ms) {
3915
+ if (stopped || folderLost) return
3916
+ if (scanTimer) clearTimeout(scanTimer)
3917
+ // Ref'd, like the setInterval it replaced: a headless client whose socket is down may have
3918
+ // nothing else holding the process open, and an unref'd net would let it exit silently.
3919
+ scanTimer = setTimeout(runAdaptiveScan, ms)
3920
+ }
3921
+
3922
+ const pendingFsEvents = new Map() // relPath -> Set<'change'|'rename'>
3923
+ let pendingFsFullScan = false
3924
+
3925
+ function flushFsEvents() {
3926
+ debounce = null
3927
+ if (stopped || folderLost) return
3928
+ if (!rootIsDir()) { pendingFsEvents.clear(); loseFolder('is gone'); return }
3929
+ if (!firstScanDone || pendingFsFullScan || pendingFsEvents.size > 256) {
3930
+ pendingFsEvents.clear()
3931
+ pendingFsFullScan = false
3932
+ scan()
3933
+ return
3934
+ }
3935
+
3936
+ const entries = [...pendingFsEvents.entries()]
3937
+ pendingFsEvents.clear()
3938
+ pendingFsFullScan = false
3939
+
3940
+ let changes = 0
3941
+ for (const [r, evts] of entries) {
3942
+ if (SKIP.has(r) || OWN_LOCAL.has(r)) continue
3943
+ if (isIgnored(r)) {
3944
+ // Never reconciled -- but HIV-P8's born-invisible notice must not wait for the next full
3945
+ // scan (up to 16 s away), and a deleted one takes its debt with it, exactly as scan() does.
3946
+ if (walkSkipsAsIgnoredFile(r)) {
3947
+ let isFile = false
3948
+ try { isFile = fs.statSync(path.join(ROOT, r)).isFile() } catch { }
3949
+ if (isFile) noticeInvisibleOne(r); else dischargeInvisible(r)
3950
+ }
3951
+ continue
3952
+ }
3953
+ const full = path.join(ROOT, r)
3954
+ let st
3955
+ try {
3956
+ st = fs.statSync(full)
3957
+ } catch {
3958
+ // Path does not exist on disk (deleted file or directory)
3959
+ dischargeUnshared(r)
3960
+ if (known.has(r) && inManifest(r)) {
3961
+ changes++
3962
+ noteLocalDelete(r) // leaves with its batch; see settleLocalDeletes
3963
+ continue
3964
+ }
3965
+ // Check if r might have been a directory containing tracked files
3966
+ const dirPrefix = r + '/'
3967
+ let wasKnownDir = false
3968
+ for (const k of known) {
3969
+ if (k.startsWith(dirPrefix)) { wasKnownDir = true; break }
3970
+ }
3971
+ if (wasKnownDir) {
3972
+ scan()
3973
+ return
3974
+ }
3975
+ continue
3976
+ }
3977
+
3978
+ // On case-insensitive filesystems, statSync('Readme.md') succeeds even if the file on disk
3979
+ // was renamed to 'README.md'. Verify exact disk path matches r:
3980
+ if (isCaseInsensitive && actualDiskPath(r) !== r) {
3981
+ dischargeUnshared(r)
3982
+ if (known.has(r) && inManifest(r)) {
3983
+ changes++
3984
+ noteLocalDelete(r)
3985
+ const diskTarget = actualDiskPath(r)
3986
+ if (diskTarget && fold(diskTarget) === fold(r)) {
3987
+ checkCaseCollision(diskTarget)
3988
+ queueManifestSet(diskTarget)
3989
+ }
3990
+ continue
3991
+ }
3992
+ }
3993
+
3994
+ if (st.isDirectory()) {
3995
+ // If a directory was created/renamed or received a 'rename' event, fall back to full scan
3996
+ if (evts.has('rename')) {
3997
+ scan()
3998
+ return
3999
+ }
4000
+ // On Windows (and some other OSes), writing to a file inside a directory also emits 'change' on the directory.
4001
+ // Ignore the directory 'change' event; the file itself is processed in this batch.
4002
+ continue
4003
+ }
4004
+
4005
+ if (!st.isFile()) continue
4006
+ if (unsharedMtimes.get(r) === st.mtimeMs && unsharedWarned.has(r)) continue
4007
+ if (mtimes.get(r) === st.mtimeMs && inManifest(r)) continue
4008
+ checkCaseCollision(r)
4009
+ if (collided.has(r)) continue
4010
+ const unshared = checkUnshared(full, st)
4011
+ if (unshared) {
4012
+ unsharedMtimes.set(r, st.mtimeMs)
4013
+ noticeUnsharedOne(r, unshared)
4014
+ continue
4015
+ } else {
4016
+ unsharedMtimes.delete(r)
4017
+ dischargeUnshared(r)
4018
+ }
4019
+ if (readText(full) === null) {
4020
+ if (isBinaryFile(full, st)) {
4021
+ mtimes.set(r, st.mtimeMs)
4022
+ reconcileBinary(r, 'local')
4023
+ changes++
4024
+ continue
4025
+ }
4026
+ continue
4027
+ }
4028
+ mtimes.set(r, st.mtimeMs)
4029
+ reconcile(r, 'local')
4030
+ changes++
4031
+ }
4032
+ }
4033
+
4034
+ function runAdaptiveScan() {
4035
+ scanTimer = null
4036
+ if (debounce) { clearTimeout(debounce); debounce = null }
4037
+ pendingFsEvents.clear()
4038
+ pendingFsFullScan = false
4039
+ const changes = scan()
4040
+ if (!watcher) {
4041
+ scanInterval = SCAN_NO_WATCHER_MS
4042
+ scheduleNextScan(scanInterval)
4043
+ return
4044
+ }
4045
+ if (changes > 0) {
4046
+ // Caught a change fs.watch missed! Snap back to minimum interval.
4047
+ scanInterval = SCAN_MIN_MS
4048
+ } else {
4049
+ // Nothing missed: back off by doubling interval up to 16s cap.
4050
+ scanInterval = Math.min(SCAN_MAX_MS, scanInterval * 2)
4051
+ }
4052
+ scheduleNextScan(scanInterval)
4053
+ }
4054
+
4055
+ const onFsEvent = (evt, fname) => {
2590
4056
  if (fname && String(fname).endsWith('.gitignore')) reloadIgnores()
4057
+ if (!fname) {
4058
+ pendingFsFullScan = true
4059
+ } else {
4060
+ const norm = String(fname).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
4061
+ if (!norm || norm === '.') {
4062
+ pendingFsFullScan = true
4063
+ } else {
4064
+ let set = pendingFsEvents.get(norm)
4065
+ if (!set) {
4066
+ set = new Set()
4067
+ pendingFsEvents.set(norm, set)
4068
+ }
4069
+ set.add(evt)
4070
+ if (pendingFsEvents.size > 256) pendingFsFullScan = true
4071
+ }
4072
+ }
2591
4073
  if (debounce) return
2592
- debounce = setTimeout(() => { debounce = null; scan() }, 40)
4074
+ debounce = setTimeout(flushFsEvents, 40)
2593
4075
  }
2594
4076
  function startWatch() {
2595
4077
  try { watcher = fs.watch(ROOT, { recursive: true }, onFsEvent) }
2596
4078
  catch { watcher = null } // recursive watch unsupported (some Linux) -> rely on scan
4079
+ // A watcher error used to be an uncaught 'error' event: deleting the room folder on Windows
4080
+ // (EPERM) killed the whole process, and with it the agent's hivecode server.
4081
+ if (watcher) watcher.on('error', onWatchError)
4082
+ }
4083
+ function onWatchError(e) {
4084
+ try { watcher && watcher.close() } catch { }
4085
+ watcher = null
4086
+ if (stopped || folderLost) return
4087
+ if (!rootIsDir()) { loseFolder('is gone'); return }
4088
+ log(`[${name}] the folder watcher failed (${(e && e.code) || e}); checking the folder every ${SCAN_NO_WATCHER_MS} ms instead`)
4089
+ scheduleNextScan(SCAN_NO_WATCHER_MS)
4090
+ }
4091
+
4092
+ // THE FOLDER IS GONE: say so once, and stop touching the disk. Every way back is a fresh join,
4093
+ // which compares the folder with the room the same way a first join does, so nothing a person
4094
+ // put back is overwritten. Stopping, rather than carrying on, is deliberate: a remote write would
4095
+ // recreate the folder holding one file, and the next pass would read the rest as deleted.
4096
+ function loseFolder(why) {
4097
+ if (folderLost || stopped) return
4098
+ folderLost = {
4099
+ at: Date.now(),
4100
+ message: `Q580: this room's folder ${why} (${ROOT}). Nothing was deleted for anyone: ` +
4101
+ `file sync stopped here, so the room keeps every file. Chat and claims still work. To get the ` +
4102
+ `room's files back, call hive_join again with the same link: an empty folder is filled from ` +
4103
+ `the room. If the folder was moved on purpose, join from its new place.`,
4104
+ }
4105
+ try { watcher && watcher.close() } catch { }
4106
+ watcher = null
4107
+ if (scanTimer) { clearTimeout(scanTimer); scanTimer = null }
4108
+ if (debounce) { clearTimeout(debounce); debounce = null }
4109
+ if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null }
4110
+ pendingFsEvents.clear()
4111
+ pendingLocalDeletes.clear()
4112
+ log(`[${name}] ${folderLost.message}`)
2597
4113
  }
2598
4114
 
2599
4115
  provider.on('sync', (s) => {
@@ -2636,7 +4152,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2636
4152
  return la - lb
2637
4153
  })
2638
4154
  const cap = maxOpen()
2639
- for (const key of localFirst.slice(0, cap)) openFile(key)
4155
+ for (const k of localFirst) checkCaseCollision(k)
4156
+ for (const key of localFirst.slice(0, cap)) queueOpenFile(key)
2640
4157
  if (final && localFirst.length > cap) {
2641
4158
  log(`[${name}] ${localFirst.length} files in this room, holding ${cap} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
2642
4159
  }
@@ -2670,9 +4187,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2670
4187
  fileTransport.ready.then(() => setTimeout(() => {
2671
4188
  try {
2672
4189
  const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
4190
+ if (want.length <= maxOpen() || stopped) return // the bound leaves nothing out: no need to stat every file
2673
4191
  const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
2674
4192
  const short = want.length - have.length
2675
- if (short <= 0 || want.length <= maxOpen() || stopped) return
4193
+ if (short <= 0) return
2676
4194
  log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
2677
4195
  `${have.length} are on disk here and ${short} are NOT. The working set is bounded to ` +
2678
4196
  `${maxOpen()} open files (HIVE_MAX_OPEN_FILES), because ` +
@@ -2684,10 +4202,78 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2684
4202
  }, 4000).unref?.())
2685
4203
  if (!scanTimer) {
2686
4204
  startWatch()
2687
- scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
4205
+ scanInterval = watcher ? SCAN_MIN_MS : SCAN_NO_WATCHER_MS
4206
+ scheduleNextScan(scanInterval)
2688
4207
  }
2689
4208
  })
2690
4209
 
4210
+ // Evaluates consensus across peer measurements for a path and version.
4211
+ function evaluateAttestations(rec, curVer, roomDocHash) {
4212
+ const currentPeers = []
4213
+ const formattedPeers = {}
4214
+ for (const [peerName, v] of Object.entries(rec || {})) {
4215
+ if (!v) continue
4216
+ const isCurrent = (v.version || 1) === (curVer.n || 1)
4217
+ if (isCurrent) {
4218
+ currentPeers.push({ peer: peerName, ...v })
4219
+ let note = ''
4220
+ if (v.stuckLagging) {
4221
+ note = ` (${v.stuckReason || 'stuck lagging'})`
4222
+ } else if (v.lagging) {
4223
+ note = ` (${v.lagNotice || 'your folder has not caught up yet'})`
4224
+ }
4225
+ formattedPeers[peerName] = { ...v, stale: false, note }
4226
+ } else {
4227
+ formattedPeers[peerName] = {
4228
+ ...v,
4229
+ stale: true,
4230
+ staleReason: `stale: attested version ${v.version || 0}, room is at ${curVer.n}`
4231
+ }
4232
+ }
4233
+ }
4234
+
4235
+ // Peers that are present, have a hash, and are NOT lagging (stuck peers are kept out of quorum too)
4236
+ const presentValid = currentPeers.filter((v) => !v.absent && v.hash && !v.lagging && !v.stuckLagging)
4237
+ const validHashes = [...new Set(presentValid.map((v) => v.hash))]
4238
+ const validSizes = [...new Set(presentValid.map((v) => v.bytes))]
4239
+
4240
+ // Peers that count toward comparison:
4241
+ // A peer lagging inside the grace period counts toward NEITHER quorum NOR divergence!
4242
+ // Compare hashes over non-lagging peers plus stuck peers only.
4243
+ const comparisonPeers = currentPeers.filter((v) => !v.absent && v.hash && (!v.lagging || v.stuckLagging))
4244
+ const comparisonHashes = [...new Set(comparisonPeers.map((v) => v.hash))]
4245
+ const comparisonSizes = [...new Set(comparisonPeers.map((v) => v.bytes))]
4246
+ const anyAbsent = currentPeers.some((v) => v.absent)
4247
+ const anyStuck = currentPeers.some((v) => v.stuckLagging)
4248
+
4249
+ // Quorum: requires at least 2 non-lagging peers of current version with identical hash, no absent, no stuck
4250
+ let quorum = presentValid.length >= 2 && validHashes.length === 1 && !anyAbsent && !anyStuck
4251
+
4252
+ // Divergence:
4253
+ // 1. Two comparison peers (non-lagging or stuck) disagree on disk hash
4254
+ // 2. OR one comparison peer is absent while another is present
4255
+ // 3. OR any peer is stuck lagging (>grace period passed without catching up)
4256
+ const split = comparisonHashes.length > 1 || (anyAbsent && comparisonPeers.length > 0) || anyStuck
4257
+
4258
+ let quorumOnOlder = false
4259
+ if (quorum && roomDocHash && validHashes[0] && roomDocHash !== validHashes[0]) {
4260
+ quorum = false
4261
+ quorumOnOlder = true
4262
+ }
4263
+
4264
+ return {
4265
+ peers: formattedPeers,
4266
+ currentPeers,
4267
+ count: currentPeers.length,
4268
+ totalCount: Object.keys(rec || {}).length,
4269
+ sizes: comparisonSizes.length ? comparisonSizes : [...new Set(currentPeers.filter(v => !v.absent && v.hash).map(v => v.bytes))],
4270
+ diverged: split,
4271
+ quorum,
4272
+ quorumOnOlder,
4273
+ version: curVer.n || 1
4274
+ }
4275
+ }
4276
+
2691
4277
  return {
2692
4278
  doc,
2693
4279
  provider,
@@ -2757,24 +4343,140 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2757
4343
  // whether the peers DISAGREE — see the swarm_attest declaration for why it is keyed per peer.
2758
4344
  attest: (relPath, bytes, lines) => {
2759
4345
  const p = String(relPath)
4346
+ const full = path.join(ROOT, p)
4347
+ let measuredHash = null
4348
+ let measuredBytes = null
4349
+ let measuredLines = null
4350
+ let absent = false
4351
+ let mismatch = null
4352
+ let diskContent = null
4353
+
4354
+ if (fs.existsSync(full)) {
4355
+ try {
4356
+ diskContent = fs.readFileSync(full, 'utf8')
4357
+ measuredHash = contentHash(diskContent)
4358
+ measuredBytes = Buffer.byteLength(diskContent, 'utf8')
4359
+ const normalized = diskContent.endsWith('\n') ? diskContent.slice(0, -1) : diskContent
4360
+ measuredLines = diskContent.length === 0 ? 0 : normalized.split(/\r?\n/).length
4361
+ const statedBytes = Number(bytes)
4362
+ if (Number.isFinite(statedBytes) && statedBytes > 0 && statedBytes !== measuredBytes) {
4363
+ mismatch = { stated: statedBytes, measured: measuredBytes }
4364
+ }
4365
+ } catch {
4366
+ absent = true
4367
+ }
4368
+ } else {
4369
+ absent = true
4370
+ }
4371
+
4372
+ // Check if room doc exists and whether disk differs from room doc
4373
+ const fe = fileDocs.get(p)
4374
+ let roomDocHash = null
4375
+ let roomDocBytes = null
4376
+ let lagging = false
4377
+ let lagDirection = null
4378
+ let lagNotice = null
4379
+
4380
+ if (fe && fe.text && fe.text.length > 0 && measuredHash) {
4381
+ const docText = fe.text.toString()
4382
+ roomDocHash = contentHash(docText)
4383
+ roomDocBytes = Buffer.byteLength(docText, 'utf8')
4384
+ if (measuredHash !== roomDocHash) {
4385
+ lagging = true
4386
+ const base = bases.get(p)
4387
+ if (base !== undefined && base === docText) {
4388
+ lagDirection = 'ahead'
4389
+ lagNotice = 'disk ahead of room doc: local edit not published to room yet (your folder has not caught up yet)'
4390
+ } else if (base !== undefined && base === diskContent) {
4391
+ lagDirection = 'behind'
4392
+ lagNotice = 'disk behind room doc: your folder has not caught up yet'
4393
+ } else {
4394
+ lagDirection = 'lagging'
4395
+ lagNotice = 'disk out of step with the room\'s copy (your folder has not caught up yet)'
4396
+ }
4397
+ }
4398
+ }
4399
+
4400
+ let curVer = swarmVersions.get(p)
4401
+ if (!curVer) {
4402
+ curVer = { n: 1, hash: measuredHash || '' }
4403
+ swarmVersions.set(p, curVer)
4404
+ }
4405
+
4406
+ const GRACE_MS = Number(process.env.HIVE_ATTEST_GRACE_MS) || 10000
2760
4407
  const prev = swarmAttest.get(p) || {}
2761
- const next = { ...prev, [name]: { bytes: Number(bytes), lines: Number(lines), at: new Date().toISOString() } }
4408
+ const prevAttest = prev[name]
4409
+
4410
+ let stuckLagging = false
4411
+ let stuckReason = null
4412
+ if (lagging && prevAttest && (prevAttest.lagging || prevAttest.stuckLagging) && prevAttest.laggingDocHash === roomDocHash) {
4413
+ const prevAt = prevAttest.at ? new Date(prevAttest.at).getTime() : 0
4414
+ const elapsedMs = Date.now() - prevAt
4415
+ if (elapsedMs >= GRACE_MS) {
4416
+ stuckLagging = true
4417
+ stuckReason = `not catching up to room doc after ${Math.round(elapsedMs / 1000)}s: disk ${measuredBytes}b vs doc ${roomDocBytes}b (${lagDirection || 'lagging'})`
4418
+ }
4419
+ }
4420
+
4421
+ const next = {
4422
+ ...prev,
4423
+ [name]: {
4424
+ bytes: measuredBytes,
4425
+ lines: measuredLines,
4426
+ hash: measuredHash,
4427
+ absent,
4428
+ mismatch,
4429
+ lagging: lagging && !stuckLagging,
4430
+ lagDirection: lagDirection || undefined,
4431
+ lagNotice: lagNotice || undefined,
4432
+ laggingDocHash: lagging ? roomDocHash : undefined,
4433
+ stuckLagging,
4434
+ stuckReason: stuckReason || undefined,
4435
+ version: curVer.n,
4436
+ at: (lagging && prevAttest && (prevAttest.lagging || prevAttest.stuckLagging) && prevAttest.laggingDocHash === roomDocHash)
4437
+ ? prevAttest.at
4438
+ : new Date().toISOString()
4439
+ }
4440
+ }
2762
4441
  swarmAttest.set(p, next)
2763
- const sizes = [...new Set(Object.values(next).map((v) => v && v.bytes))]
2764
- const split = sizes.length > 1
2765
- if (split) {
2766
- // Announce, do not throw. The measuring agent did nothing wrong and its report is valid; the
2767
- // ROOM is what is broken. Throwing here would punish whoever happened to measure second.
2768
- const detail = Object.entries(next).map(([who, v]) => `${who}:${v.bytes}b/${v.lines}L`).join(' ')
2769
- say(`[!] DIVERGENCE on ${p} — peers hold DIFFERENT bytes: ${detail}. Nobody may write this path until it is reconciled; mark it path:${p}=POISONED if it cannot be.`)
2770
- }
2771
- return { path: p, peers: next, count: Object.keys(next).length, sizes, diverged: split }
4442
+
4443
+ const evaluated = evaluateAttestations(next, curVer, roomDocHash)
4444
+ if (evaluated.diverged) {
4445
+ const detail = evaluated.currentPeers.map((v) => {
4446
+ if (v.absent) return `${v.peer}:ABSENT`
4447
+ if (v.stuckLagging) return `${v.peer}:${v.bytes}b/${v.lines}L[STUCK:${v.stuckReason || 'not catching up'}]`
4448
+ return `${v.peer}:${v.bytes}b/${v.lines}L`
4449
+ }).join(' ')
4450
+ say(`[!] DIVERGENCE on ${p} — peers hold DIFFERENT content: ${detail}. Nobody may write this path until it is reconciled; mark it path:${p}=POISONED if it cannot be.`)
4451
+ }
4452
+
4453
+ return {
4454
+ path: p,
4455
+ mismatch,
4456
+ measuredBytes,
4457
+ measuredLines,
4458
+ measuredHash,
4459
+ lagging: lagging && !stuckLagging,
4460
+ lagNotice,
4461
+ stuckLagging,
4462
+ stuckReason,
4463
+ ...evaluated
4464
+ }
2772
4465
  },
2773
4466
  verified: (relPath) => {
2774
4467
  const p = String(relPath)
4468
+ const curVer = swarmVersions.get(p) || { n: 1, hash: '' }
2775
4469
  const rec = swarmAttest.get(p) || {}
2776
- const sizes = [...new Set(Object.values(rec).map((v) => v && v.bytes))]
2777
- return { path: p, peers: rec, count: Object.keys(rec).length, sizes, diverged: sizes.length > 1, quorum: Object.keys(rec).length >= 2 && sizes.length === 1 }
4470
+ const fe = fileDocs.get(p)
4471
+ let roomDocHash = null
4472
+ if (fe && fe.text && fe.text.length > 0) {
4473
+ roomDocHash = contentHash(fe.text.toString())
4474
+ }
4475
+ const evaluated = evaluateAttestations(rec, curVer, roomDocHash)
4476
+ return {
4477
+ path: p,
4478
+ ...evaluated
4479
+ }
2778
4480
  },
2779
4481
  say, // post a coordination message
2780
4482
  assign, // direct a task at a participant (needs approval if target AI has an owner)
@@ -2797,7 +4499,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2797
4499
  const held = coordinator.acquire(r, i)
2798
4500
  // Fire-and-forget on purpose: claim's contract is synchronous and changing that would
2799
4501
  // break every caller. Use fetch(path) directly when you need to WAIT for the bytes.
2800
- if (held) { try { fetchFile(r).catch(() => {}) } catch { } }
4502
+ if (held) {
4503
+ try { fetchFile(r).catch(() => {}) } catch { }
4504
+ try {
4505
+ const full = path.join(ROOT, r)
4506
+ const unshared = checkUnshared(full)
4507
+ if (unshared) noticeUnsharedOne(r, unshared)
4508
+ } catch { }
4509
+ }
2801
4510
  return held
2802
4511
  }, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
2803
4512
  // HIV-P18: bring a file the room has but this folder does not. Resolves when the BYTES have
@@ -2906,10 +4615,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2906
4615
  const u = s.user
2907
4616
  if (!u || !u.name) continue
2908
4617
  const prev = seen.get(u.name) || {}
2909
- seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive) })
4618
+ seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive), listener: !!(u.listener || prev.listener) || undefined, app: u.app || prev.app || undefined })
2910
4619
  }
2911
4620
  return [...seen.values()]
2912
4621
  },
4622
+ // ANOTHER PARTICIPANT ALREADY USING THIS NAME. Measured 3 Oct 2026: a second agent joined as
4623
+ // "Claude" beside the first, was told "others here: none yet", and BOTH were then told
4624
+ // "claimed x.js -- it's yours"; the second one's write landed in the first one's folder while
4625
+ // the first held the file. hive_members showed a single Claude. (The same thing once fused a
4626
+ // file to 32,038 lines between two agents called Hermes.) Not counted: this connection, a
4627
+ // listener (it runs under its agent's name on purpose), this same agent a moment ago in this
4628
+ // same folder (same iid), and a person in a second window when this is a person too. A state
4629
+ // with no iid is an older build that cannot say, so it counts.
4630
+ sameNameElsewhere: () => {
4631
+ const me = provider.awareness.clientID
4632
+ const lc = String(name).toLowerCase()
4633
+ const out = []
4634
+ for (const [id, s] of provider.awareness.getStates()) {
4635
+ const u = s && s.user
4636
+ if (id === me || !u || !u.name || String(u.name).toLowerCase() !== lc) continue
4637
+ if (u.listener || u.iid === IID) continue
4638
+ if (kind === 'human' && u.kind === 'human') continue
4639
+ out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app })
4640
+ }
4641
+ return out
4642
+ },
2913
4643
  // Live connection state. An agent parked on hive_wait has no way to tell a quiet
2914
4644
  // room from a dead socket, so it guesses -- and the guess it reaches for is
2915
4645
  // hive_join, which tears the session down and rebuilds it (see hive-mcp.js: join
@@ -2945,10 +4675,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2945
4675
  // So the trade stays and the reporting changes. `files` keeps meaning open docs, because
2946
4676
  // tests/open-file-cap-test.mjs:71 pins it as the proof the cap is respected. What is added
2947
4677
  // is the pair that was missing: what the ROOM has, and what this client actually HOLDS.
4678
+ flushRoomDoc()
2948
4679
  const roomFiles = (() => { try { return [...manifest.keys()].filter((r) => canOpen(r)).length } catch { return 0 } })()
4680
+ // One directory walk, not one existsSync per file: that loop cost 292 ms a call in a
4681
+ // 1,000-file room (dev machine) and blocked the loop throughout. walk() reads a directory
4682
+ // per call; only a path the walk did not list (not on disk yet, or ignored since it was
4683
+ // added) is still checked on its own, so the count means exactly what it meant before.
2949
4684
  const held = (() => {
2950
4685
  let n = 0
2951
- try { for (const r of manifest.keys()) { if (canOpen(r) && fs.existsSync(path.join(ROOT, r))) n++ } } catch { }
4686
+ try {
4687
+ const onDisk = new Set(walk(ROOT).map(rel))
4688
+ for (const r of manifest.keys()) { if (canOpen(r) && !collided.has(r) && (onDisk.has(r) || fs.existsSync(path.join(ROOT, r)))) n++ }
4689
+ } catch { }
2952
4690
  return n
2953
4691
  })()
2954
4692
  return {
@@ -2963,6 +4701,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2963
4701
  filesMissing: Math.max(0, roomFiles - held),
2964
4702
  notSyncing: notSyncing.length,
2965
4703
  notSyncingFiles: notSyncing.slice(0, 20),
4704
+ unsharedFiles: [...unsharedWarned.entries()].map(([p, info]) => ({
4705
+ path: p,
4706
+ reason: info.reason,
4707
+ size: info.size,
4708
+ })),
2966
4709
  // The working set and its bound. `openCap` x agents must stay under the relay's
2967
4710
  // maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
2968
4711
  // how this room actually works — both are things an operator can only act on if
@@ -2971,14 +4714,50 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2971
4714
  // 'mux' = every file on this client's one socket; 'socket' = a socket per file.
2972
4715
  transport: fileTransport.mode,
2973
4716
  evicted,
4717
+ scanInterval,
4718
+ ...(folderLost ? { folderLost: folderLost.message } : {}),
4719
+ ...(unwritable.size ? { unwritable: [...unwritable].map(([p, code]) => ({ path: p, code })) } : {}),
4720
+ ...(withheldNow().length ? { withheld: withheldNow() } : {}),
4721
+ ...(collided.size ? { collided: [...collided.entries()].map(([p, info]) => ({ path: p, with: info.with })) } : {}),
2974
4722
  }
2975
4723
  },
4724
+ unshared: () => [...unsharedWarned.entries()].map(([p, info]) => ({ path: p, reason: info.reason, size: info.size })),
4725
+ // The room is told when a change cannot be written here, but in this participant's own name, and
4726
+ // an agent is never handed its own lines. This is how its tools tell it instead.
4727
+ unwritable: () => [...unwritable].map(([p, code]) => ({ path: p, code })),
4728
+ // Files held here because they contain a key: path, kind, line. Never the key.
4729
+ withheld: () => withheldNow(),
4730
+ collided: () => [...collided.entries()].map(([p, info]) => ({ path: p, with: info.with })),
4731
+ _fileSnapSize: (r) => fileDocs.get(r)?.snap?.size || 0,
4732
+ _fileSnaps: (r) => [...(fileDocs.get(r)?.snap?.values() || [])],
4733
+ _fileDoc: (r) => fileDocs.get(r),
4734
+ _fileText: (r) => fileDocs.get(r)?.text?.toString() || '',
4735
+ _watcher: () => watcher,
4736
+ _scanInterval: () => scanInterval,
4737
+ // Just the two booleans hive_say, hive_check/hive_wait and hive_delivered need on every call.
4738
+ // status() also counts filesHeld, one existsSync per file in the room -- 1,000 on a 1,000-file
4739
+ // room, every call; in one profile 0.8 s of a 12 s publish went to polling status() alone.
4740
+ // ...plus folderLost, because the agent learns its folder is gone only where it is looking.
4741
+ connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
4742
+ _openTurns: () => openTurns,
4743
+ _scanCount: () => scanCount,
2976
4744
  stop: () => {
2977
- if (scanTimer) clearInterval(scanTimer)
2978
- if (debounce) clearTimeout(debounce)
4745
+ try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
4746
+ try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
4747
+ stopped = true
4748
+ try { flushRoomDoc() } catch { }
4749
+ if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
4750
+ if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
4751
+ openQueue.length = 0
4752
+ openQueueSet.clear()
4753
+ if (scanTimer) clearTimeout(scanTimer)
4754
+ if (debounce) { clearTimeout(debounce); debounce = null }
4755
+ for (const tm of collidedTimers.values()) clearTimeout(tm)
4756
+ collidedTimers.clear()
4757
+ pendingFsEvents.clear()
4758
+ pendingFsFullScan = false
2979
4759
  if (editingClearTimer) clearTimeout(editingClearTimer)
2980
4760
  try { watcher && watcher.close() } catch { }
2981
- stopped = true
2982
4761
  // The shared socket first: closing it ends every channel at once on the relay, instead of
2983
4762
  // one CLOSE frame per file down a socket that is about to go anyway.
2984
4763
  try { fileTransport.stop() } catch { }
@@ -2987,7 +4766,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2987
4766
  try { provider.destroy() } catch { }
2988
4767
  try { doc.destroy() } catch { }
2989
4768
  // A room you have left should stop colouring what your agents recall.
2990
- if (memoryLinked) try { grmLink.unlinkRoom(ROOT, log) } catch { }
4769
+ if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
2991
4770
  // Last, so a crash anywhere above still frees the folder on the next run via the
2992
4771
  // stale-PID path rather than leaving it locked.
2993
4772
  try { releaseLock() } catch { }