hivecode-mcp 0.14.1 → 0.14.3

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 (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +106 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +674 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +709 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +92 -12
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +354 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +637 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1761 -1668
  41. package/hive-room-watcher.js +623 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2417 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +617 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +856 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +695 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +11 -3
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +434 -60
  72. package/token.js +201 -201
package/sync.js CHANGED
@@ -753,6 +753,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
753
753
  })
754
754
  } catch { /* provenance must never break the sync path */ }
755
755
  }
756
+ // NOTHING THIS CLIENT PUBLISHES ENTERS THE ROOM UNSIGNED (0.14.3 P0-3). With the fork gate on, a
757
+ // local edit is signed by captureLocal, which needs a baseline (`shadow`) to diff against. An edit
758
+ // made before the file first synced on a fresh join has none, and the rejoin and creation-race
759
+ // merges publish without passing through it at all. The content still landed, so the file's chain
760
+ // broke: `pr` then refused with a false Q506 ("the branch holds content this room has never seen")
761
+ // on a branch nobody had touched, and credited the file to the last agent that HAD signed (8-9 Nov
762
+ // mock run, test.js in demo-3). So after any local publish: if no receipt yields exactly what was
763
+ // published, sign it now, from the room's copy it replaced -- the state the room provably held.
764
+ // Only when THIS client's own edit went out unsigned: if captureLocal signed the authored text (or
765
+ // anyone signed what was published), nothing is added, so the fork gate sees exactly what it saw
766
+ // before. A receipt for a merged result is never added beside a signed edit: detectFork stops at a
767
+ // current text that has a receipt, and that must not hide a real fork.
768
+ function signPublished(relPath, roomBefore, published, authored = published) {
769
+ if (!PROVENANCE || !identity || published == null || published === roomBefore) return
770
+ const fe = fileDocs.get(relPath); if (!fe || !fe.ledger) return
771
+ const hp = contentHash(published), ha = authored == null ? null : contentHash(authored)
772
+ const rc = fe.ledger.toArray()
773
+ if (rc.some((r) => r && (r.contentHash === hp || (ha && r.author === identity.id && r.contentHash === ha)))) return
774
+ stampIntent(relPath, roomBefore == null ? '' : roomBefore, published)
775
+ }
756
776
  function attestHead(relPath, landed, base) {
757
777
  if (!PROVENANCE || !identity || landed == null) return
758
778
  const fe = fileDocs.get(relPath); if (!fe || !fe.head) return
@@ -1052,6 +1072,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1052
1072
  if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
1053
1073
  if (intent) myIntents.set(relPath, String(intent)) // my declared WHY — what my receipt will sign
1054
1074
  const full = path.join(ROOT, relPath)
1075
+ // Written before this file synced: remember what it replaced, the edit's real ancestor (P0-1b).
1076
+ // The OLDEST text this folder had since the file started waiting (diskAtOpen) when there is one: an
1077
+ // earlier edit with the agent's own file tool sits between it and this write, and the room can only
1078
+ // have held the older text (0.14.3 P0-1b-R3).
1079
+ { const fe0 = fileDocs.get(relPath); if (!(fe0 && fe0.synced) && !preSyncBase.has(relPath)) { try { preSyncBase.set(relPath, fe0 && typeof fe0.diskAtOpen === 'string' ? fe0.diskAtOpen : readDiskText(full)) } catch { } } }
1055
1080
  // An authored write keeps this disk's ending for the file, like every engine write (readText).
1056
1081
  try { fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, crlfSet().has(relPath) ? toLF(content).replace(/\n/g, '\r\n') : content) }
1057
1082
  catch (e) { return { error: 'write failed: ' + e.message } }
@@ -1061,7 +1086,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1061
1086
  if (FORK_GATE) captureLocal(relPath)
1062
1087
  reconcile(relPath, 'local')
1063
1088
  const fe = fileDocs.get(relPath)
1064
- const landed = fe && fe.synced ? fe.text.toString() : content
1089
+ // The room's copy is known only once the file synced; before that, say nothing about it rather
1090
+ // than echo the content back as if it had landed (0.14.3 P0-1). hive_edit asks whenSent().
1091
+ const landed = fe && fe.synced ? fe.text.toString() : null
1065
1092
  let disk = content; try { disk = readDiskText(full) } catch { }
1066
1093
  // A new file's doc has not synced yet when this returns, so the gate in reconcile has not run:
1067
1094
  // judge the content here too, or the answer would be "syncing to every peer" for a file that
@@ -1069,6 +1096,97 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1069
1096
  const held = withheld.get(relPath) || (SHARE_SECRETS ? null : newSecrets(content, fe && fe.synced ? fe.text.toString() : '')[0])
1070
1097
  return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk), ...(held ? { withheld: describeSecret(held) } : {}) }
1071
1098
  }
1099
+ // hive_edit old/new: the agent STATES the lines it edited, so nothing has to guess its base.
1100
+ //
1101
+ // A disk write cannot say which version it was made from, and from the texts alone "B replaced
1102
+ // A's change" and "B wrote from a copy older than A's change" can be identical: four rules that
1103
+ // guessed each deleted work silently (1 Nov 2026), so a plain write still merges against the fork.
1104
+ // Here `old` must be whole lines that appear exactly once in the room's copy. The room's copy is
1105
+ // then the base by construction: B's lines land as written and every other line is untouched. A
1106
+ // stale reader's `old` no longer matches the lines that changed under it, so it is refused, and the
1107
+ // refusal shows the lines as they are now.
1108
+ function editLines(relPath, oldText, newText, intent) {
1109
+ relPath = String(relPath || '').replace(/\\/g, '/')
1110
+ const NOTHING = 'Nothing was written.'
1111
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return { error: `need both old and new. ${NOTHING}` }
1112
+ if (!oldText.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
1113
+ if (!relPath || !isSafeRelPath(relPath)) return { error: `unsafe path. ${NOTHING}` }
1114
+ if (folderLost) return { error: folderLost.message }
1115
+ if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file. ${NOTHING}` }
1116
+ if (!canOpen(relPath)) return { error: `${relPath} is out of your scope. ${NOTHING}` }
1117
+ if (!canWrite(relPath)) return { error: `${relPath} is read-only for you. ${NOTHING}` }
1118
+ // The room's copy is only a base while it IS the room's copy: not offline, and not while someone
1119
+ // else holds the file and can change it underneath (antigravity-3, 1 Nov).
1120
+ if (!provider.wsconnected) return { error: `you are not connected to the room, so its copy of ${relPath} cannot be checked. ${NOTHING}` }
1121
+ const holder = coordinator.sense(relPath)
1122
+ if (holder && holder.by && holder.by !== name) return { error: `${holder.by} holds ${relPath}${holder.intent ? ` (${holder.intent})` : ''}, so its lines can change under you. Claim it once they release it. ${NOTHING}` }
1123
+ const fe = fileDocs.get(relPath)
1124
+ if (!fe || !fe.synced) {
1125
+ // In the room but not loaded here (outside the open set, or still arriving): never send the
1126
+ // agent to content mode, which would replace a file it has not read (antigravity-3, 1 Nov).
1127
+ if (inManifest(relPath)) {
1128
+ if (!fe) { try { openFile(relPath) } catch { } }
1129
+ return { loading: true, error: `${relPath} is in the room but not loaded here yet; it is loading now, retry in a few seconds. Do not write it with content: that would replace the room's copy without reading it. ${NOTHING}` }
1130
+ }
1131
+ return { error: `${relPath} is not in the room, so there are no lines to replace. Create it with content instead. ${NOTHING}` }
1132
+ }
1133
+ // The file's own channel, not just the room's: `fe.synced` latches at the first sync, so while
1134
+ // this file's channel is down or catching up its copy can be stale with the room connected, and a
1135
+ // stale match fuses with the newer edit on reconnect (antigravity-3, 1 Nov: "applyTax(subtotal)20").
1136
+ if (!fe.provider || !fe.provider.wsconnected || !fe.provider.synced) return { error: `${relPath}'s own channel is reconnecting, so its current lines cannot be checked; retry in a moment. ${NOTHING}` }
1137
+ // Through the LF view, like every engine read: the room holds LF, and a CRLF disk (a Windows checkout)
1138
+ // compared raw differs on every line, so every old/new edit there was refused (handover-edit H16).
1139
+ let disk = null; try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
1140
+ const cur = fe.text.toString()
1141
+ // Nothing is sent first: a refused edit sends nothing at all (antigravity-3, 1 Nov). Whatever makes
1142
+ // this disk differ from the room (an unsent save, a key held back, a frozen kept edit, markers) would
1143
+ // be written over by replacing lines in the room's copy, so it is refused.
1144
+ if (disk !== cur) {
1145
+ const why = disk === null ? 'it is missing from your folder'
1146
+ : withheld.has(relPath) ? 'it holds a key that is kept out of the room'
1147
+ : keptUnsent.has(relPath) ? 'it holds an edit that never reached the room, frozen until you save the file again'
1148
+ : hasConflictMarkers(disk) ? 'it carries conflict markers'
1149
+ : 'it has changes the room has not taken yet; if you just saved it, retry in a moment'
1150
+ return { error: `your copy of ${relPath} differs from the room's (${why}). Replacing lines in the room's copy would write over that. ${NOTHING}` }
1151
+ }
1152
+ if (keptUnsent.has(relPath)) keptUnsent.delete(relPath) // identical to the room: nothing left to keep
1153
+ const eol = cur.includes('\r\n') ? '\r\n' : '\n'
1154
+ // One trailing line break is not part of the lines: "b\n" and "b" both mean the line b, so a break
1155
+ // dropped or added on one side can never join two lines or leave a blank one (antigravity-3, 1 Nov).
1156
+ const asLines = (s) => s.replace(/\r?\n$/, '').replace(/\r?\n/g, eol)
1157
+ const o = asLines(oldText), n = asLines(newText)
1158
+ if (!o.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
1159
+ const atStart = (i) => i === 0 || cur[i - 1] === '\n'
1160
+ // A match ending in a line break ("b\n" left after stripping "b\n\n") means a blank line follows,
1161
+ // so it may not end at the end of the file; and never between the \r and \n of one line end
1162
+ // (antigravity-3, 1 Nov).
1163
+ const atEnd = (j) => (j === cur.length && !o.endsWith('\n')) || (cur[j] === '\n' && cur[j - 1] !== '\r') || cur.startsWith('\r\n', j)
1164
+ const at = [], whole = []
1165
+ for (let i = cur.indexOf(o); i !== -1; i = cur.indexOf(o, i + 1)) { at.push(i); if (atStart(i) && atEnd(i + o.length)) whole.push(i) }
1166
+ const lineAt = (i) => { const s = cur.lastIndexOf('\n', i - 1) + 1; let e = cur.indexOf('\n', i + o.length); if (e === -1) e = cur.length; return cur.slice(s, e).replace(/\r$/, '') }
1167
+ const last = (() => { try { const rs = fe.ledger ? fe.ledger.toArray() : []; const r = rs[rs.length - 1]; return r && r.name ? ` It was last changed by ${r.name}.` : '' } catch { return '' } })()
1168
+ if (whole.length > 1) return { error: `old appears ${whole.length} times in ${relPath}; add the lines around it so it matches once. ${NOTHING}` }
1169
+ if (!whole.length && at.length) {
1170
+ return { error: `old must be whole lines. In the room's copy it is part of ${at.length === 1 ? 'this line' : 'these lines'}, as ${at.length === 1 ? 'it is' : 'they are'} now:\n${[...new Set(at.map(lineAt))].slice(0, 3).join('\n')}\nIf that is what you edited, pass the whole line.${last} ${NOTHING}` }
1171
+ }
1172
+ if (!whole.length) {
1173
+ // Show the line that looks most like the first line of `old`, so the agent sees what changed.
1174
+ const first = o.split(/\r?\n/).find((l) => l.trim()) || ''
1175
+ const pre = (a, b) => { let k = 0; while (k < a.length && k < b.length && a[k] === b[k]) k++; return k }
1176
+ let best = '', score = 0
1177
+ for (const l of cur.split(/\r?\n/)) { const k = pre(l.trim(), first.trim()); if (k > score) { score = k; best = l } }
1178
+ return { error: `old is not in the room's copy of ${relPath}: it changed since you read it.${last}${score >= 6 ? ` The closest line now is:\n${best}\n` : ' '}Read the file again and pass the lines as they are now. ${NOTHING}` }
1179
+ }
1180
+ if (o === n) return { ok: true, noop: true, file: relPath }
1181
+ let s = whole[0], e = s + o.length
1182
+ // An EMPTY new deletes the lines together with one line break, so no blank line is left behind.
1183
+ // new "\n" is a blank line, not a delete (antigravity-3, 1 Nov).
1184
+ if (!newText) { if (cur.startsWith('\r\n', e)) e += 2; else if (cur[e] === '\n') e += 1; else if (s > 0) s -= (s > 1 && cur[s - 2] === '\r') ? 2 : 1 }
1185
+ const next = cur.slice(0, s) + n + cur.slice(e)
1186
+ // The declared base: this edit was made against the room's copy, by construction.
1187
+ forkBases.set(relPath, cur); shadow.set(relPath, cur)
1188
+ return { ...editFile(relPath, next, intent), via: 'lines' }
1189
+ }
1072
1190
  // Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
1073
1191
  // this is monotonic + convergent: every peer contributes what it sees and the set grows
1074
1192
  // to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
@@ -1242,6 +1360,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1242
1360
  // "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
1243
1361
  // would create a second CRDT history and duplicate/garble content on rejoin).
1244
1362
  const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
1363
+ // EDITS NOT YET ACCEPTED BY THE RELAY (0.14.3 P0-1). A file enters on any local change to its doc
1364
+ // and leaves once its channel is connected, synced and its socket holds no unsent bytes. Synced
1365
+ // means the sync exchange ran after the change (it carries anything the socket missed), so
1366
+ // "connected + synced + nothing buffered" is "the relay has it". Over HTTPS long-poll the bytes sit
1367
+ // in the client until the relay accepts them; an agent that left in that window lost the edit while
1368
+ // hive_edit said it was syncing (8-9 Nov mock run). leave() waits on this set, hive_edit reports it,
1369
+ // and eviction never closes a file that is still in it.
1370
+ const unsentLocal = new Set()
1245
1371
 
1246
1372
  // REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
1247
1373
  // silence. That is right for a flaky network and badly wrong for a relay that is full: on
@@ -1344,6 +1470,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1344
1470
  // LANDED, because an un-awaited open is the same false success one layer down: the caller
1345
1471
  // reads the directory, sees nothing, and concludes the file is absent.
1346
1472
  function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
1473
+ arrivedNew.delete(relPath) // asked for by name here: in use, so the full grace window applies
1347
1474
  const r = String(relPath)
1348
1475
  if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
1349
1476
  if (collided.has(r)) {
@@ -1400,6 +1527,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1400
1527
  function openFile(relPath) {
1401
1528
  let e = fileDocs.get(relPath)
1402
1529
  if (e) return e
1530
+ if (newRemote.delete(relPath)) arrivedNew.add(relPath)
1403
1531
  const fdoc = new Y.Doc()
1404
1532
  const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
1405
1533
  const text = fdoc.getText('content')
@@ -1431,8 +1559,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1431
1559
  // each peer's DISK — never into the shared text — so conflict markers can't fuse.
1432
1560
  const conflict = fdoc.getMap('conflict')
1433
1561
  const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
1434
- e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
1562
+ // diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
1563
+ // the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
1564
+ // is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
1565
+ let diskAtOpen
1566
+ try { const f0 = path.join(ROOT, relPath); if (fs.statSync(f0).size <= MAX_BYTES) diskAtOpen = readDiskText(f0) } catch { diskAtOpen = undefined }
1567
+ e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen }
1435
1568
  fileDocs.set(relPath, e)
1569
+ // A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
1570
+ fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
1436
1571
  // From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
1437
1572
  // 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
1438
1573
  if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
@@ -1454,6 +1589,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1454
1589
  reconcile(relPath, 'remote')
1455
1590
  detectFork(relPath)
1456
1591
  }
1592
+ preSyncBase.delete(relPath) // used by the first reconcile or not at all, never by a later reopen (review R4)
1593
+ e.diskAtOpen = undefined
1594
+ // A new file that has just landed on disk frees its place for the next one (a burst of new
1595
+ // files moves at sync speed, not at the 30 s grace), and lets waiting new files in.
1596
+ if (arrivedNew.has(relPath)) {
1597
+ const t = setTimeout(() => {
1598
+ if (stopped) return
1599
+ if (fileDocs.size > maxOpen()) evictIfOverCap(null)
1600
+ if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
1601
+ }, 50)
1602
+ if (t.unref) t.unref()
1603
+ }
1457
1604
  }
1458
1605
  })
1459
1606
  // A refused upgrade lands here. The provider will keep retrying by itself, which is the
@@ -1478,8 +1625,91 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1478
1625
  // its bound — so this is where the least-recently-used ones are let go.
1479
1626
  touched.set(relPath, Date.now())
1480
1627
  evictIfOverCap(relPath)
1628
+ scheduleEvictRecheck()
1481
1629
  return e
1482
1630
  }
1631
+ // `await await f()` THAT NOBODY WROTE (0.14.3 P0-5-3). Two peers making the same sync -> async change
1632
+ // at once each insert `await ` before the same call (each merge is right on its own), and the shared
1633
+ // text keeps both inserts. Remove the outer one, here, before the room's copy reaches the disk:
1634
+ // pure deletes of the spans lang.stackedAwaits finds (AST-level, whitespace-only between, so a
1635
+ // comment or a string that says "await await" is never touched). On the same converged text every
1636
+ // peer deletes the same characters, so two peers doing it at once remove them once. Never a
1637
+ // whole-text diff: that would delete and re-insert everything between two such spots, and two
1638
+ // peers re-inserting the same middle is the fusion this engine exists to prevent. Skipped when
1639
+ // this folder's own copy has one (its author wrote it) and for a file this member cannot write.
1640
+ function dropStackedAwaits(relPath, fe, disk) {
1641
+ try {
1642
+ if (!supports(relPath) || !canWrite(relPath)) return false
1643
+ const lang = languageFor(relPath)
1644
+ if (!lang || typeof lang.stackedAwaits !== 'function') return false
1645
+ const t = fe.text.toString()
1646
+ if (!/\bawait\s+await\b/.test(t)) return false
1647
+ if (typeof disk === 'string' && /\bawait\s+await\b/.test(disk) && lang.stackedAwaits(disk).length) return false
1648
+ const spans = lang.stackedAwaits(t)
1649
+ if (!spans.length) return false
1650
+ fe.doc.transact(() => { for (let i = spans.length - 1; i >= 0; i--) fe.text.delete(spans[i][0], spans[i][1] - spans[i][0]) })
1651
+ log(`[${name}] ${relPath}: removed ${spans.length} stacked \`await\` left by two peers making the same change at once`)
1652
+ return true
1653
+ } catch { return false }
1654
+ }
1655
+ /** Has the relay accepted every local change to this file? (See unsentLocal.) */
1656
+ function isSentNow(relPath) {
1657
+ if (!unsentLocal.has(relPath)) return true
1658
+ const fe = fileDocs.get(relPath)
1659
+ if (!fe) return false
1660
+ const p = fe.provider
1661
+ if (!(p && p.wsconnected && p.synced && !(p.bufferedAmount > 0))) return false
1662
+ // Delivered is not enough: the room must HOLD what this folder has. On first contact a local edit
1663
+ // the room cannot prove it has seen is kept here and deliberately NOT published (and a refused
1664
+ // fused merge publishes nothing); calling that "sent" told an agent its work was in the room when
1665
+ // the room still had the old copy. A deleted file, or one this side cannot read as text, is judged
1666
+ // on delivery alone.
1667
+ // A BINARY file's content is in fe.bin, not fe.text (which is "" for it): compared as text it never
1668
+ // matched, so a binary this member added was never "sent" -- leave() waited its full timeout and
1669
+ // named it as lost, and eviction never closed it (Opus-Cloud's review of 0.14.3). The room holds
1670
+ // what this folder has when the bytes' sha256 is the one the room records, as reconcileBinary writes it.
1671
+ if (fe.bin && fe.bin.has('data')) {
1672
+ let bytes = null
1673
+ try { bytes = fs.readFileSync(path.join(ROOT, relPath)) } catch { }
1674
+ if (bytes && crypto.createHash('sha256').update(bytes).digest('hex') !== fe.bin.get('sha')) return false
1675
+ unsentLocal.delete(relPath)
1676
+ return true
1677
+ }
1678
+ let disk = null
1679
+ try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
1680
+ if (disk != null && !hasConflictMarkers(disk) && toLF(disk) !== toLF(fe.text.toString())) return false
1681
+ unsentLocal.delete(relPath)
1682
+ return true
1683
+ }
1684
+ /** Resolves true once the relay has this file's local changes, false if not within timeoutMs. */
1685
+ async function whenSent(relPath, timeoutMs = 3000) {
1686
+ relPath = String(relPath || '').replace(/\\/g, '/')
1687
+ const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
1688
+ for (;;) {
1689
+ if (isSentNow(relPath)) return true
1690
+ if (stopped || Date.now() >= end) return false
1691
+ await new Promise((r) => setTimeout(r, 50))
1692
+ }
1693
+ }
1694
+ const roomBuffered = () => { try { return (provider.ws && provider.ws.bufferedAmount) || 0 } catch { return 0 } }
1695
+ // LEAVE WITHOUT LOSING WHAT WAS JUST WRITTEN. stop() tears everything down at once; leave() first
1696
+ // hands the pending work over (debounced disk edits, deletes, the room doc), waits until the relay
1697
+ // has accepted every local change -- files and chat/claims alike -- and only then stops. Whatever
1698
+ // did not make it within timeoutMs is NAMED in the answer instead of vanishing (0.14.3 P0-1).
1699
+ async function leave({ timeoutMs = 10000 } = {}) {
1700
+ if (stopped) return { unsent: [], roomUnsent: false }
1701
+ try { if (debounce) { clearTimeout(debounce); flushFsEvents() } } catch { }
1702
+ try { flushFirstContact() } catch { }
1703
+ try { settleLocalDeletes() } catch { }
1704
+ try { flushRoomDoc() } catch { }
1705
+ const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
1706
+ const pendingNow = () => [...unsentLocal].filter((r) => !isSentNow(r))
1707
+ while (Date.now() < end && (pendingNow().length || roomBuffered() > 0)) await new Promise((r) => setTimeout(r, 50))
1708
+ const unsent = pendingNow()
1709
+ const roomUnsent = roomBuffered() > 0
1710
+ stopAll()
1711
+ return { unsent, roomUnsent }
1712
+ }
1483
1713
  function closeFile(relPath) {
1484
1714
  const e = fileDocs.get(relPath)
1485
1715
  if (!e) return
@@ -1529,6 +1759,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1529
1759
  const touched = new Map() // relPath -> last use (ms)
1530
1760
  const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
1531
1761
  let evicted = 0
1762
+ // NEW FILES GET IN EVEN AT THE BOUND (Claude-cloud, 8 Nov 2026). A file a teammate creates AFTER
1763
+ // this client joined, with no copy on this disk, used to be skipped outright once the working set
1764
+ // was full: the manifest observer queued it, queueOpenFile returned at the bound, and nothing ever
1765
+ // came back for it -- measured: never arrived in 180 s, mux or per-file, in any room fuller than
1766
+ // HIVE_MAX_OPEN_FILES (and a 640-file room is fuller than the per-file 128). Such a key now opens
1767
+ // anyway, and evictIfOverCap makes room by letting go of the least recently used. Bounded: at
1768
+ // most NEW_HEADROOM over the bound at once, so a burst of hundreds of new files cannot open
1769
+ // hundreds of sockets; the rest WAIT in the queue (never dropped) until the re-check frees room.
1770
+ // Files that were in the room at join keep the old trade ("the rest sync when touched").
1771
+ const newRemote = new Set() // keys added to the room after join, not on this disk, not yet opened
1772
+ // ...and the ones that were opened that way and have not been used here since. Once such a file is
1773
+ // ON DISK it may be let go without waiting out the grace window: it arrived, which is all it was
1774
+ // opened for. LRU order still decides, so older idle files go first.
1775
+ const arrivedNew = new Set()
1776
+ const NEW_HEADROOM = 32
1777
+ let evictRecheck = null
1778
+ // evictIfOverCap only runs when a file opens, and a file inside its grace window cannot be let
1779
+ // go -- so a set pushed over the bound by new files would stay over it. Look again once the
1780
+ // grace has passed, evict down to the bound, and let any waiting new files in.
1781
+ function scheduleEvictRecheck() {
1782
+ if (evictRecheck || stopped) return
1783
+ if (fileDocs.size <= maxOpen() && !newRemote.size) return
1784
+ // While new files are landing or waiting, look again every second: a landed new file is
1785
+ // evictable as soon as it is on disk, and that must not wait out a grace window that could be
1786
+ // minutes long. Otherwise (only grace-protected files over the bound) look once the grace ends.
1787
+ const ms = (newRemote.size || arrivedNew.size) ? 1000 : EVICT_GRACE_MS + 1000
1788
+ evictRecheck = setTimeout(() => {
1789
+ evictRecheck = null
1790
+ if (stopped) return
1791
+ evictIfOverCap(null)
1792
+ if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
1793
+ scheduleEvictRecheck()
1794
+ }, ms)
1795
+ if (evictRecheck.unref) evictRecheck.unref()
1796
+ }
1532
1797
  function evictIfOverCap(keep) {
1533
1798
  if (fileDocs.size <= maxOpen()) return
1534
1799
  const now = Date.now()
@@ -1536,18 +1801,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1536
1801
  const cand = []
1537
1802
  for (const [r, e] of fileDocs) {
1538
1803
  if (r === keep || !e.synced) continue
1804
+ if (!isSentNow(r)) continue // closing it would drop an edit the relay has not accepted yet
1539
1805
  if (conflicted.has(r)) continue
1540
1806
  try { if (e.conflict && e.conflict.size) continue } catch { }
1541
1807
  if (isClaimed(r)) continue
1542
1808
  const t = touched.get(r) || 0
1543
- if (now - t < EVICT_GRACE_MS) continue
1809
+ const landedNew = arrivedNew.has(r) && fs.existsSync(path.join(ROOT, r))
1810
+ if (now - t < EVICT_GRACE_MS && !landedNew) continue
1544
1811
  cand.push([r, t])
1545
1812
  }
1546
1813
  cand.sort((a, b) => a[1] - b[1]) // least recently used first
1547
1814
  let over = fileDocs.size - maxOpen()
1548
1815
  for (const [r] of cand) {
1549
1816
  if (over-- <= 0) break
1550
- closeFile(r); touched.delete(r); evicted++
1817
+ closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
1551
1818
  }
1552
1819
  }
1553
1820
 
@@ -1565,6 +1832,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1565
1832
  const cand = []
1566
1833
  for (const [r, e] of fileDocs) {
1567
1834
  if (conflicted.has(r) || isClaimed(r)) continue
1835
+ if (unsentLocal.has(r)) continue // a local edit the relay has not accepted: closing it loses work
1568
1836
  try { if (e.conflict && e.conflict.size) continue } catch { }
1569
1837
  cand.push([r, touched.get(r) || 0])
1570
1838
  }
@@ -1572,7 +1840,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1572
1840
  let over = fileDocs.size - cap
1573
1841
  for (const [r] of cand) {
1574
1842
  if (over-- <= 0) break
1575
- closeFile(r); touched.delete(r); evicted++
1843
+ closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
1576
1844
  }
1577
1845
  }
1578
1846
 
@@ -1737,6 +2005,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1737
2005
  // merge. Keyed by room — the same folder rejoined against a different room must not
1738
2006
  // inherit an ancestor from unrelated history.
1739
2007
  const priorShadow = new Map()
2008
+ // relPath -> the disk text an authored write (editFile) replaced while the file had not synced yet:
2009
+ // that write's true ancestor, used by first contact (0.14.3 P0-1b).
2010
+ const preSyncBase = new Map()
1740
2011
  const bases = new Map() // last synced content per file (advances on every reconcile)
1741
2012
  const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
1742
2013
  // forkBases advances only on local authorship / first adoption — NOT when a
@@ -1936,6 +2207,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1936
2207
  const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
1937
2208
 
1938
2209
  const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
2210
+ const recentlyLocallyRemovedCase = new Map() // fold(r) -> { path: r, at: Date.now() }
1939
2211
  const collided = new Map() // collided relPath -> { with: canonical relPath }
1940
2212
  const collidedWarned = new Set() // relPath announced to room
1941
2213
  const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
@@ -2263,6 +2535,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2263
2535
  }
2264
2536
  if (!fe.synced && origin !== 'local') return
2265
2537
  touchFile(relPath)
2538
+ if (origin !== 'remote') arrivedNew.delete(relPath) // a local change: an ordinary file now (see reconcile)
2266
2539
 
2267
2540
  if (origin === 'local') {
2268
2541
  if (!canWrite(relPath)) {
@@ -2414,8 +2687,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2414
2687
  // (duplicated/garbled content on rejoin). So: open if needed, then defer until
2415
2688
  // the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
2416
2689
  fe = fileDocs.get(relPath)
2417
- if (!fe) { if (disk === null) return; openFile(relPath); return }
2418
- if (!fe.synced) return
2690
+ // A local change waiting for its file to sync is an unsent edit too: the sync handler sends it,
2691
+ // and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
2692
+ if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
2693
+ if (!fe.synced) { if (origin === 'local' && disk !== null) unsentLocal.add(relPath); return }
2419
2694
  // A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
2420
2695
  // room in either direction would decide something nobody decided: the room's next change would
2421
2696
  // be written over it, and an unchanged save merged against itself pulled the room's old copy over
@@ -2439,6 +2714,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2439
2714
  else if (withheld.has(relPath)) clearWithheld(relPath)
2440
2715
  }
2441
2716
  touchFile(relPath) // reconciling a file IS using it — keep it in the working set
2717
+ // ...but only a LOCAL change makes a just-arrived new file an ordinary one with the full grace.
2718
+ // A remote reconcile is the arrival itself, and must not take away its early eviction.
2719
+ if (origin !== 'remote') arrivedNew.delete(relPath)
2442
2720
  // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
2443
2721
  // this file, record any authored edit sitting on disk. The edit enters the provenance
2444
2722
  // DAG first; whatever happens to the disk afterwards can no longer lose it.
@@ -2488,6 +2766,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2488
2766
  // clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
2489
2767
  if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
2490
2768
  }
2769
+ dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
2491
2770
  const docText = fe.text.toString()
2492
2771
  // An empty shared doc is authoritative ONLY if we have history for this path.
2493
2772
  // A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
@@ -2531,6 +2810,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2531
2810
  }
2532
2811
  if (disk === docText) {
2533
2812
  ensureExactDiskCasing(relPath)
2813
+ if (!inManifest(relPath) && origin === 'local') {
2814
+ const key = fold(relPath)
2815
+ const prevCase = recentlyLocallyRemovedCase.get(key)
2816
+ if (prevCase && (Date.now() - prevCase.at < 60000)) {
2817
+ recentlyLocallyRemovedCase.delete(key)
2818
+ queueManifestSet(relPath)
2819
+ }
2820
+ }
2534
2821
  // CREATING a file is editing it. This branch is where a locally-authored NEW file
2535
2822
  // lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
2536
2823
  // runs disk and doc already agree -- so it returns below, before the markEditing() at
@@ -2662,7 +2949,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2662
2949
  return
2663
2950
  }
2664
2951
  }
2665
- if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
2952
+ if (r0.text !== docText) { fe.doc.transact(() => applyDiff(yt, r0.text)); signPublished(relPath, docText, r0.text, disk) }
2666
2953
  if (r0.text !== disk) writeToDisk(relPath, r0.text)
2667
2954
  if (r0.conflict) conflicted.add(relPath)
2668
2955
  bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
@@ -2675,6 +2962,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2675
2962
  // local copy may be newer than the room's (edited while disconnected, or the room
2676
2963
  // came back stale). This is the data-loss case that was seen live.
2677
2964
  let anc = priorShadow.get(relPath)
2965
+ // An edit THIS session made through the medium before the file had synced has a known ancestor:
2966
+ // the disk text it replaced (0.14.3 P0-1b). Without it, a fresh folder's first contact had no
2967
+ // ancestor, refused to publish the edit, and a later merge wrote the room's copy back over it.
2968
+ // ONLY when the room held that text (Claude-cloud's review R1): a folder carrying a local change L
2969
+ // the room never saw, edited before sync, would otherwise merge with ancestor L, read the room as
2970
+ // "removed L", and delete L from disk and room. Not proven held -> no ancestor -> the frozen keep.
2971
+ let preSync = false
2972
+ // An edit made with the agent's OWN file tool before the file synced left no record (only hive_edit
2973
+ // writes one), so first contact had no ancestor and froze it as a kept, unsent edit (P0-1b-R3).
2974
+ // What the disk held when the file opened is that ancestor, under the same rule: only if the room
2975
+ // provably held it.
2976
+ if (anc === undefined && !preSyncBase.has(relPath) && typeof fe.diskAtOpen === 'string' && disk !== null && fe.diskAtOpen !== disk) {
2977
+ preSyncBase.set(relPath, fe.diskAtOpen)
2978
+ }
2979
+ if (anc === undefined && preSyncBase.has(relPath)) {
2980
+ const pre = preSyncBase.get(relPath)
2981
+ const held = accountFor({ rel: relPath, branchText: pre, roomText: docText, receipts: fe.ledger ? fe.ledger.toArray() : [] }).state
2982
+ if (held === DESCENDS || held === IDENTICAL) { anc = pre; preSync = true }
2983
+ }
2984
+ preSyncBase.delete(relPath)
2678
2985
  // ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
2679
2986
  // bytes BEFORE the relay accepts them. A write held behind someone else's claim
2680
2987
  // (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
@@ -2711,7 +3018,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2711
3018
  // the local side first so the pre-merge bytes are recoverable regardless of outcome.
2712
3019
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
2713
3020
  const rj = icrMerge3(anc, disk, docText, relPath)
2714
- if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
3021
+ if (rj.text !== docText) { fe.doc.transact(() => applyDiff(yt, rj.text)); signPublished(relPath, docText, rj.text, disk) }
2715
3022
  if (rj.text !== disk) writeToDisk(relPath, rj.text)
2716
3023
  if (rj.conflict) conflicted.add(relPath)
2717
3024
  bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
@@ -2720,7 +3027,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2720
3027
  say(unsent
2721
3028
  ? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
2722
3029
  `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.`
2723
- : `⚠ ${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.`)
3030
+ : preSync
3031
+ ? `${relPath}: ${name} edited it before the room's copy had loaded here — the edit was merged with the room's copy and sent${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`
3032
+ : `⚠ ${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.`)
2724
3033
  } catch { }
2725
3034
  return
2726
3035
  }
@@ -2747,7 +3056,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2747
3056
  // Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
2748
3057
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
2749
3058
  bases.set(relPath, disk); forkBases.set(relPath, disk)
2750
- log(`[${name}] ⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted; local disk preserved`)
3059
+ // FROZEN, not merely kept (0.14.3 P0-1b). With bases = the local copy and nothing else, the NEXT
3060
+ // reconcile of this file (the watcher seeing the agent's own write, the scan, any remote change)
3061
+ // merged (base local, ours local, theirs room) = the room's copy, wrote it over the local edit
3062
+ // and told the room "kept the changes that landed in between (nothing lost)": the edit was gone
3063
+ // from the room AND the disk (5 of 8 poll runs on 0.14.2, tests/diag/presync-edit-race.mjs;
3064
+ // traced by Claude-cloud). Frozen like a kept unsent edit: nothing syncs for this file until its
3065
+ // author saves it again, and that save replaces the room's copy. The worst case is now "kept,
3066
+ // and said", never "lost".
3067
+ let keptAt = 0; try { keptAt = fs.statSync(full).mtimeMs } catch { }
3068
+ keptUnsent.set(relPath, keptAt)
3069
+ // Said in the room, not only logged (review R5): the agent has to act on it.
3070
+ try {
3071
+ say(`⚠ ${relPath}: ${name}'s copy differs from the room's, and the room cannot tell which is newer (${st.state}: ${name} changed it before the room's copy had loaded here). ` +
3072
+ `Neither was written over the other: ${name}'s is kept on disk, both are in the file's history, and nothing syncs for this file until ${name} saves it again. That save replaces the room's copy. Claim ${relPath} first.`)
3073
+ } catch { }
3074
+ log(`[${name}] ⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted; local disk preserved and frozen until saved again`)
2751
3075
  noteFirstContact('kept', relPath)
2752
3076
  return
2753
3077
  }
@@ -2838,7 +3162,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2838
3162
  res = { text: disk, conflict: false } // author built on the latest or cleanly rewrote — trust it
2839
3163
  } else {
2840
3164
  res = icrMerge3(fork, disk, docText, relPath)
2841
- reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
3165
+ reAdded = !res.conflict && res.text !== disk && res.text !== docText // we restored remote lines a stale write had dropped
3166
+ // ...and when the merge gives back exactly the room's copy, the local edit did NOT survive:
3167
+ // never call that "nothing lost" (review R2). Keep the local copy and say so.
3168
+ if (!res.conflict && res.text === docText && disk !== docText) {
3169
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy replaced by the room\'s copy in a merge' })
3170
+ try { say(`⚠ ${relPath}: ${name}'s local change did not survive the merge with the room's copy (the room's version was taken). ${name}'s copy is in the file's history.`) } catch { }
3171
+ }
2842
3172
  // OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
2843
3173
  // rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
2844
3174
  // tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
@@ -2878,7 +3208,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2878
3208
  icrWarned.set(relPath, why)
2879
3209
  log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
2880
3210
  try {
2881
- say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
3211
+ say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately: a plain file write, or hive_edit with content, merges against the other version again.`)
2882
3212
  } catch { /* the log still carries it */ }
2883
3213
  }
2884
3214
  return
@@ -2911,7 +3241,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2911
3241
  // Gate ON: the author's INTENT was already captured at the source (captureLocal, top
2912
3242
  // of this pass) — here we only advance the authority head to the landed content.
2913
3243
  // Gate OFF (default): the proven landed-transition stamp, unchanged.
2914
- if (FORK_GATE) { if (res.text !== docText) attestHead(relPath, res.text, docText) }
3244
+ if (FORK_GATE) { if (res.text !== docText) { signPublished(relPath, docText, res.text, disk); attestHead(relPath, res.text, docText) } }
2915
3245
  else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
2916
3246
  }
2917
3247
  if (res.text !== disk) writeToDisk(relPath, res.text)
@@ -2924,7 +3254,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2924
3254
  if (hasMarkers && !conflicted.has(relPath)) {
2925
3255
  conflicted.add(relPath)
2926
3256
  log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
2927
- try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
3257
+ try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing, by saving the file without them or by passing the marked block as old to hive_edit old/new.`) } catch { }
2928
3258
  } else if (!hasMarkers && conflicted.has(relPath)) {
2929
3259
  conflicted.delete(relPath)
2930
3260
  try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
@@ -3614,20 +3944,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3614
3944
  function checkBuilds() {
3615
3945
  // An old engine cannot speak for itself, so SOMEBODY current must -- but only one: the reporting
3616
3946
  // client whose name sorts first. Otherwise every current client in the room says the same thing.
3947
+ // Listeners (listener: true, syncFiles: false) do not sync or edit files and do not report builds.
3617
3948
  const reporters = [name]
3618
- for (const s of provider.awareness.getStates().values()) { const u = s && s.user; if (u && u.name && u.hv) reporters.push(u.name) }
3949
+ const currentEngines = new Set()
3950
+ for (const s of provider.awareness.getStates().values()) {
3951
+ const u = s && s.user
3952
+ if (!u || !u.name || u.v !== PROTOCOL_VERSION || u.listener) continue
3953
+ if (u.hv) reporters.push(u.name)
3954
+ if (u.hv === VERSION_LABEL) currentEngines.add(u.name)
3955
+ }
3619
3956
  const speakForOld = reporters.sort()[0] === name
3957
+
3620
3958
  for (const s of provider.awareness.getStates().values()) {
3621
3959
  const u = s && s.user
3622
- if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION) continue
3960
+ if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION || u.listener) continue
3623
3961
  const human = String(u.kind || '').toLowerCase() === 'human'
3624
3962
  let theirs = ''
3625
3963
  if (u.hv) {
3626
3964
  if (u.hv === VERSION_LABEL || name > u.name) continue
3627
- theirs = `hivecode ${u.hv}`
3965
+ const olderOpen = currentEngines.has(u.name) ? ` (an older connection from ${u.name} is still open)` : ''
3966
+ theirs = `hivecode ${u.hv}${olderOpen}`
3628
3967
  } else if (!human) {
3629
3968
  if (!speakForOld) continue
3630
- theirs = `a hivecode build older than build reporting (before ${VERSION})`
3969
+ const olderOpen = currentEngines.has(u.name) ? ` (an older connection from ${u.name} is still open)` : ''
3970
+ theirs = `a hivecode build older than build reporting (before ${VERSION})${olderOpen}`
3631
3971
  } else continue
3632
3972
  const key = `${u.name}|${u.hv || '-'}`
3633
3973
  if (buildWarned.has(key)) continue
@@ -3849,7 +4189,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3849
4189
  if (!send.length) return
3850
4190
  for (const r of send) queueManifestDelete(r)
3851
4191
  flushRoomDoc()
4192
+ const now = Date.now()
4193
+ for (const [k, v] of recentlyLocallyRemovedCase) { if (now - v.at > 60000) recentlyLocallyRemovedCase.delete(k) }
3852
4194
  for (const r of send) {
4195
+ recentlyLocallyRemovedCase.set(fold(r), { path: r, at: now })
3853
4196
  closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
3854
4197
  if (isCaseInsensitive) {
3855
4198
  const cf = fold(r)
@@ -4027,26 +4370,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4027
4370
  const key = openQueue.shift()
4028
4371
  openQueueSet.delete(key)
4029
4372
  if (fileDocs.size >= maxOpen()) {
4030
- openQueue.length = 0
4031
- openQueueSet.clear()
4032
- break
4373
+ // At the bound a key from the join sweep waits for a touch, as before -- but a NEW file is
4374
+ // never dropped: it opens within the headroom, or goes back to the head of the queue for
4375
+ // the re-check (scheduleEvictRecheck) to let in once room is made.
4376
+ if (!newRemote.has(key)) continue
4377
+ if (fileDocs.size >= maxOpen() + NEW_HEADROOM) {
4378
+ openQueue.unshift(key); openQueueSet.add(key)
4379
+ scheduleEvictRecheck()
4380
+ break
4381
+ }
4033
4382
  }
4034
4383
  // STILL WANTED? A key can wait here while a peer deletes the file. Its document still exists on
4035
4384
  // the relay, so opening it would bring the deleted file back to this disk and publish it again
4036
4385
  // for everyone. Wanted = still in the room, or on this disk (a new local file not yet published).
4037
4386
  const wanted = inManifest(key) || fs.existsSync(path.join(ROOT, key))
4387
+ if (!wanted) newRemote.delete(key)
4038
4388
  if (wanted && canOpen(key) && !SKIP.has(key) && !isIgnored(key) && !fileDocs.has(key)) {
4039
4389
  openFile(key)
4040
4390
  openedCount++
4041
4391
  }
4042
4392
  }
4043
- if (openQueue.length && !stopped && fileDocs.size < maxOpen()) {
4393
+ if (openQueue.length && !stopped && (fileDocs.size < maxOpen() || (newRemote.size && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
4044
4394
  openTimer = setImmediate(drainOpenQueue)
4045
4395
  }
4046
4396
  }
4047
4397
 
4048
4398
  function queueOpenFile(key) {
4049
- if (fileDocs.has(key) || fileDocs.size >= maxOpen() || openQueueSet.has(key)) return
4399
+ if (fileDocs.has(key) || (fileDocs.size >= maxOpen() && !newRemote.has(key)) || openQueueSet.has(key)) return
4050
4400
  openQueueSet.add(key)
4051
4401
  if (fs.existsSync(path.join(ROOT, key))) openQueue.unshift(key)
4052
4402
  else openQueue.push(key)
@@ -4093,7 +4443,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4093
4443
  }
4094
4444
  }
4095
4445
  }
4446
+ recentlyLocallyRemovedCase.delete(fold(key))
4096
4447
  pendingManifestOps.delete(key)
4448
+ newRemote.delete(key); arrivedNew.delete(key) // deleted while new or queued: nothing to wait for
4097
4449
  closeFile(key)
4098
4450
  { const qi = openQueue.indexOf(key); if (qi >= 0) { openQueue.splice(qi, 1); openQueueSet.delete(key) } } // deleted while queued
4099
4451
  if (!stamp) stamp = deletedStamp() // one folder per batch, so a bulk delete comes back together
@@ -4109,7 +4461,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4109
4461
  for (const [key, change] of others) {
4110
4462
  if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
4111
4463
  checkCaseCollision(key)
4112
- if (ev.changes.keys.size <= 2 && fileDocs.size < maxOpen()) {
4464
+ // NEW = created after this client joined (roomAtJoin is the room at the first sync) and not
4465
+ // on this disk. The join's own manifest arrives before roomAtJoin exists, so it is never new.
4466
+ const isNew = !!roomAtJoin && change.action === 'add' && !roomAtJoin.has(key) && !fs.existsSync(path.join(ROOT, key))
4467
+ if (isNew) newRemote.add(key)
4468
+ if (ev.changes.keys.size <= 2 && (fileDocs.size < maxOpen() || (isNew && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
4113
4469
  openFile(key)
4114
4470
  } else {
4115
4471
  queueOpenFile(key)
@@ -4501,6 +4857,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4501
4857
  }
4502
4858
  }
4503
4859
 
4860
+ function stopAll() {
4861
+ try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
4862
+ try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
4863
+ stopped = true
4864
+ try { flushRoomDoc() } catch { }
4865
+ if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
4866
+ if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
4867
+ openQueue.length = 0
4868
+ openQueueSet.clear()
4869
+ if (scanTimer) clearTimeout(scanTimer)
4870
+ if (debounce) { clearTimeout(debounce); debounce = null }
4871
+ for (const tm of collidedTimers.values()) clearTimeout(tm)
4872
+ collidedTimers.clear()
4873
+ pendingFsEvents.clear()
4874
+ pendingFsFullScan = false
4875
+ if (editingClearTimer) clearTimeout(editingClearTimer)
4876
+ try { watcher && watcher.close() } catch { }
4877
+ // The shared socket first: closing it ends every channel at once on the relay, instead of
4878
+ // one CLOSE frame per file down a socket that is about to go anyway.
4879
+ try { fileTransport.stop() } catch { }
4880
+ for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
4881
+ try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
4882
+ try { provider.destroy() } catch { }
4883
+ try { doc.destroy() } catch { }
4884
+ // A room you have left should stop colouring what your agents recall.
4885
+ if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
4886
+ // Last, so a crash anywhere above still frees the folder on the next run via the
4887
+ // stale-PID path rather than leaving it locked.
4888
+ try { releaseLock() } catch { }
4889
+ // A final flush of the ancestor record. The debounced save may still be pending, and
4890
+ // losing it would make the next join see no common ancestor -- the exact condition
4891
+ // that produced the rejoin clobber.
4892
+ try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
4893
+ }
4894
+
4504
4895
  return {
4505
4896
  doc,
4506
4897
  provider,
@@ -4821,6 +5212,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4821
5212
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
4822
5213
  resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
4823
5214
  edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
5215
+ editLines, // hive_edit old/new: whole lines found once in the room's copy, which is then the base
4824
5216
  // --- non-writer review & approval ---
4825
5217
  review,
4826
5218
  reviews: getReviews,
@@ -4856,7 +5248,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4856
5248
  const u = s.user
4857
5249
  if (!u || !u.name) continue
4858
5250
  const prev = seen.get(u.name) || {}
4859
- 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 })
5251
+ const hv = (prev.hv === VERSION_LABEL || u.hv === VERSION_LABEL)
5252
+ ? VERSION_LABEL
5253
+ : ((prev.listener && !u.listener && u.hv) || (!prev.listener && u.listener && prev.hv) || u.hv || prev.hv)
5254
+ seen.set(u.name, {
5255
+ ...prev,
5256
+ ...u,
5257
+ hv,
5258
+ editing: u.editing || prev.editing,
5259
+ owner: u.owner || prev.owner,
5260
+ passive: !!(u.passive || prev.passive),
5261
+ listener: !!(u.listener || prev.listener) || undefined,
5262
+ app: u.app || prev.app || undefined
5263
+ })
4860
5264
  }
4861
5265
  return [...seen.values()]
4862
5266
  },
@@ -4952,6 +5356,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4952
5356
  // how this room actually works — both are things an operator can only act on if
4953
5357
  // they can see them.
4954
5358
  openCap: maxOpen(),
5359
+ newWaiting: newRemote.size,
4955
5360
  // 'mux' = every file on this client's one socket; 'socket' = a socket per file.
4956
5361
  transport: fileTransport.mode,
4957
5362
  evicted,
@@ -4982,40 +5387,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4982
5387
  connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
4983
5388
  _openTurns: () => openTurns,
4984
5389
  _scanCount: () => scanCount,
4985
- stop: () => {
4986
- try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
4987
- try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
4988
- stopped = true
4989
- try { flushRoomDoc() } catch { }
4990
- if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
4991
- if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
4992
- openQueue.length = 0
4993
- openQueueSet.clear()
4994
- if (scanTimer) clearTimeout(scanTimer)
4995
- if (debounce) { clearTimeout(debounce); debounce = null }
4996
- for (const tm of collidedTimers.values()) clearTimeout(tm)
4997
- collidedTimers.clear()
4998
- pendingFsEvents.clear()
4999
- pendingFsFullScan = false
5000
- if (editingClearTimer) clearTimeout(editingClearTimer)
5001
- try { watcher && watcher.close() } catch { }
5002
- // The shared socket first: closing it ends every channel at once on the relay, instead of
5003
- // one CLOSE frame per file down a socket that is about to go anyway.
5004
- try { fileTransport.stop() } catch { }
5005
- for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
5006
- try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
5007
- try { provider.destroy() } catch { }
5008
- try { doc.destroy() } catch { }
5009
- // A room you have left should stop colouring what your agents recall.
5010
- if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
5011
- // Last, so a crash anywhere above still frees the folder on the next run via the
5012
- // stale-PID path rather than leaving it locked.
5013
- try { releaseLock() } catch { }
5014
- // A final flush of the ancestor record. The debounced save may still be pending, and
5015
- // losing it would make the next join see no common ancestor -- the exact condition
5016
- // that produced the rejoin clobber.
5017
- try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
5018
- },
5390
+ stop: stopAll,
5391
+ leave, // async: hand pending work over, wait for the relay to accept it, then stop (0.14.3 P0-1)
5392
+ whenSent, // async (file, ms): has the relay accepted this file's local changes yet?
5019
5393
  }
5020
5394
  }
5021
5395