hivecode-mcp 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +105 -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 +608 -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 +695 -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 +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -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 +600 -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 +1668 -1668
  41. package/hive-room-watcher.js +562 -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 +2384 -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 +578 -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 +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -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 +1 -1
  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 +169 -11
  72. package/token.js +201 -201
package/sync.js CHANGED
@@ -1069,6 +1069,97 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1069
1069
  const held = withheld.get(relPath) || (SHARE_SECRETS ? null : newSecrets(content, fe && fe.synced ? fe.text.toString() : '')[0])
1070
1070
  return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk), ...(held ? { withheld: describeSecret(held) } : {}) }
1071
1071
  }
1072
+ // hive_edit old/new: the agent STATES the lines it edited, so nothing has to guess its base.
1073
+ //
1074
+ // A disk write cannot say which version it was made from, and from the texts alone "B replaced
1075
+ // A's change" and "B wrote from a copy older than A's change" can be identical: four rules that
1076
+ // guessed each deleted work silently (1 Nov 2026), so a plain write still merges against the fork.
1077
+ // Here `old` must be whole lines that appear exactly once in the room's copy. The room's copy is
1078
+ // then the base by construction: B's lines land as written and every other line is untouched. A
1079
+ // stale reader's `old` no longer matches the lines that changed under it, so it is refused, and the
1080
+ // refusal shows the lines as they are now.
1081
+ function editLines(relPath, oldText, newText, intent) {
1082
+ relPath = String(relPath || '').replace(/\\/g, '/')
1083
+ const NOTHING = 'Nothing was written.'
1084
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return { error: `need both old and new. ${NOTHING}` }
1085
+ if (!oldText.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
1086
+ if (!relPath || !isSafeRelPath(relPath)) return { error: `unsafe path. ${NOTHING}` }
1087
+ if (folderLost) return { error: folderLost.message }
1088
+ if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file. ${NOTHING}` }
1089
+ if (!canOpen(relPath)) return { error: `${relPath} is out of your scope. ${NOTHING}` }
1090
+ if (!canWrite(relPath)) return { error: `${relPath} is read-only for you. ${NOTHING}` }
1091
+ // The room's copy is only a base while it IS the room's copy: not offline, and not while someone
1092
+ // else holds the file and can change it underneath (antigravity-3, 1 Nov).
1093
+ if (!provider.wsconnected) return { error: `you are not connected to the room, so its copy of ${relPath} cannot be checked. ${NOTHING}` }
1094
+ const holder = coordinator.sense(relPath)
1095
+ 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}` }
1096
+ const fe = fileDocs.get(relPath)
1097
+ if (!fe || !fe.synced) {
1098
+ // In the room but not loaded here (outside the open set, or still arriving): never send the
1099
+ // agent to content mode, which would replace a file it has not read (antigravity-3, 1 Nov).
1100
+ if (inManifest(relPath)) {
1101
+ if (!fe) { try { openFile(relPath) } catch { } }
1102
+ 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}` }
1103
+ }
1104
+ return { error: `${relPath} is not in the room, so there are no lines to replace. Create it with content instead. ${NOTHING}` }
1105
+ }
1106
+ // The file's own channel, not just the room's: `fe.synced` latches at the first sync, so while
1107
+ // this file's channel is down or catching up its copy can be stale with the room connected, and a
1108
+ // stale match fuses with the newer edit on reconnect (antigravity-3, 1 Nov: "applyTax(subtotal)20").
1109
+ 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}` }
1110
+ // Through the LF view, like every engine read: the room holds LF, and a CRLF disk (a Windows checkout)
1111
+ // compared raw differs on every line, so every old/new edit there was refused (handover-edit H16).
1112
+ let disk = null; try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
1113
+ const cur = fe.text.toString()
1114
+ // Nothing is sent first: a refused edit sends nothing at all (antigravity-3, 1 Nov). Whatever makes
1115
+ // this disk differ from the room (an unsent save, a key held back, a frozen kept edit, markers) would
1116
+ // be written over by replacing lines in the room's copy, so it is refused.
1117
+ if (disk !== cur) {
1118
+ const why = disk === null ? 'it is missing from your folder'
1119
+ : withheld.has(relPath) ? 'it holds a key that is kept out of the room'
1120
+ : keptUnsent.has(relPath) ? 'it holds an edit that never reached the room, frozen until you save the file again'
1121
+ : hasConflictMarkers(disk) ? 'it carries conflict markers'
1122
+ : 'it has changes the room has not taken yet; if you just saved it, retry in a moment'
1123
+ return { error: `your copy of ${relPath} differs from the room's (${why}). Replacing lines in the room's copy would write over that. ${NOTHING}` }
1124
+ }
1125
+ if (keptUnsent.has(relPath)) keptUnsent.delete(relPath) // identical to the room: nothing left to keep
1126
+ const eol = cur.includes('\r\n') ? '\r\n' : '\n'
1127
+ // One trailing line break is not part of the lines: "b\n" and "b" both mean the line b, so a break
1128
+ // dropped or added on one side can never join two lines or leave a blank one (antigravity-3, 1 Nov).
1129
+ const asLines = (s) => s.replace(/\r?\n$/, '').replace(/\r?\n/g, eol)
1130
+ const o = asLines(oldText), n = asLines(newText)
1131
+ if (!o.trim()) return { error: `old is empty: pass the whole lines you are replacing, exactly as they are now. ${NOTHING}` }
1132
+ const atStart = (i) => i === 0 || cur[i - 1] === '\n'
1133
+ // A match ending in a line break ("b\n" left after stripping "b\n\n") means a blank line follows,
1134
+ // so it may not end at the end of the file; and never between the \r and \n of one line end
1135
+ // (antigravity-3, 1 Nov).
1136
+ const atEnd = (j) => (j === cur.length && !o.endsWith('\n')) || (cur[j] === '\n' && cur[j - 1] !== '\r') || cur.startsWith('\r\n', j)
1137
+ const at = [], whole = []
1138
+ 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) }
1139
+ 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$/, '') }
1140
+ 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 '' } })()
1141
+ if (whole.length > 1) return { error: `old appears ${whole.length} times in ${relPath}; add the lines around it so it matches once. ${NOTHING}` }
1142
+ if (!whole.length && at.length) {
1143
+ 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}` }
1144
+ }
1145
+ if (!whole.length) {
1146
+ // Show the line that looks most like the first line of `old`, so the agent sees what changed.
1147
+ const first = o.split(/\r?\n/).find((l) => l.trim()) || ''
1148
+ const pre = (a, b) => { let k = 0; while (k < a.length && k < b.length && a[k] === b[k]) k++; return k }
1149
+ let best = '', score = 0
1150
+ for (const l of cur.split(/\r?\n/)) { const k = pre(l.trim(), first.trim()); if (k > score) { score = k; best = l } }
1151
+ 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}` }
1152
+ }
1153
+ if (o === n) return { ok: true, noop: true, file: relPath }
1154
+ let s = whole[0], e = s + o.length
1155
+ // An EMPTY new deletes the lines together with one line break, so no blank line is left behind.
1156
+ // new "\n" is a blank line, not a delete (antigravity-3, 1 Nov).
1157
+ 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 }
1158
+ const next = cur.slice(0, s) + n + cur.slice(e)
1159
+ // The declared base: this edit was made against the room's copy, by construction.
1160
+ forkBases.set(relPath, cur); shadow.set(relPath, cur)
1161
+ return { ...editFile(relPath, next, intent), via: 'lines' }
1162
+ }
1072
1163
  // Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
1073
1164
  // this is monotonic + convergent: every peer contributes what it sees and the set grows
1074
1165
  // to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
@@ -1344,6 +1435,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1344
1435
  // LANDED, because an un-awaited open is the same false success one layer down: the caller
1345
1436
  // reads the directory, sees nothing, and concludes the file is absent.
1346
1437
  function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
1438
+ arrivedNew.delete(relPath) // asked for by name here: in use, so the full grace window applies
1347
1439
  const r = String(relPath)
1348
1440
  if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
1349
1441
  if (collided.has(r)) {
@@ -1400,6 +1492,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1400
1492
  function openFile(relPath) {
1401
1493
  let e = fileDocs.get(relPath)
1402
1494
  if (e) return e
1495
+ if (newRemote.delete(relPath)) arrivedNew.add(relPath)
1403
1496
  const fdoc = new Y.Doc()
1404
1497
  const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
1405
1498
  const text = fdoc.getText('content')
@@ -1454,6 +1547,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1454
1547
  reconcile(relPath, 'remote')
1455
1548
  detectFork(relPath)
1456
1549
  }
1550
+ // A new file that has just landed on disk frees its place for the next one (a burst of new
1551
+ // files moves at sync speed, not at the 30 s grace), and lets waiting new files in.
1552
+ if (arrivedNew.has(relPath)) {
1553
+ const t = setTimeout(() => {
1554
+ if (stopped) return
1555
+ if (fileDocs.size > maxOpen()) evictIfOverCap(null)
1556
+ if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
1557
+ }, 50)
1558
+ if (t.unref) t.unref()
1559
+ }
1457
1560
  }
1458
1561
  })
1459
1562
  // A refused upgrade lands here. The provider will keep retrying by itself, which is the
@@ -1478,6 +1581,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1478
1581
  // its bound — so this is where the least-recently-used ones are let go.
1479
1582
  touched.set(relPath, Date.now())
1480
1583
  evictIfOverCap(relPath)
1584
+ scheduleEvictRecheck()
1481
1585
  return e
1482
1586
  }
1483
1587
  function closeFile(relPath) {
@@ -1529,6 +1633,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1529
1633
  const touched = new Map() // relPath -> last use (ms)
1530
1634
  const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
1531
1635
  let evicted = 0
1636
+ // NEW FILES GET IN EVEN AT THE BOUND (Claude-cloud, 8 Nov 2026). A file a teammate creates AFTER
1637
+ // this client joined, with no copy on this disk, used to be skipped outright once the working set
1638
+ // was full: the manifest observer queued it, queueOpenFile returned at the bound, and nothing ever
1639
+ // came back for it -- measured: never arrived in 180 s, mux or per-file, in any room fuller than
1640
+ // HIVE_MAX_OPEN_FILES (and a 640-file room is fuller than the per-file 128). Such a key now opens
1641
+ // anyway, and evictIfOverCap makes room by letting go of the least recently used. Bounded: at
1642
+ // most NEW_HEADROOM over the bound at once, so a burst of hundreds of new files cannot open
1643
+ // hundreds of sockets; the rest WAIT in the queue (never dropped) until the re-check frees room.
1644
+ // Files that were in the room at join keep the old trade ("the rest sync when touched").
1645
+ const newRemote = new Set() // keys added to the room after join, not on this disk, not yet opened
1646
+ // ...and the ones that were opened that way and have not been used here since. Once such a file is
1647
+ // ON DISK it may be let go without waiting out the grace window: it arrived, which is all it was
1648
+ // opened for. LRU order still decides, so older idle files go first.
1649
+ const arrivedNew = new Set()
1650
+ const NEW_HEADROOM = 32
1651
+ let evictRecheck = null
1652
+ // evictIfOverCap only runs when a file opens, and a file inside its grace window cannot be let
1653
+ // go -- so a set pushed over the bound by new files would stay over it. Look again once the
1654
+ // grace has passed, evict down to the bound, and let any waiting new files in.
1655
+ function scheduleEvictRecheck() {
1656
+ if (evictRecheck || stopped) return
1657
+ if (fileDocs.size <= maxOpen() && !newRemote.size) return
1658
+ // While new files are landing or waiting, look again every second: a landed new file is
1659
+ // evictable as soon as it is on disk, and that must not wait out a grace window that could be
1660
+ // minutes long. Otherwise (only grace-protected files over the bound) look once the grace ends.
1661
+ const ms = (newRemote.size || arrivedNew.size) ? 1000 : EVICT_GRACE_MS + 1000
1662
+ evictRecheck = setTimeout(() => {
1663
+ evictRecheck = null
1664
+ if (stopped) return
1665
+ evictIfOverCap(null)
1666
+ if (openQueue.length && !openTimer) openTimer = setImmediate(drainOpenQueue)
1667
+ scheduleEvictRecheck()
1668
+ }, ms)
1669
+ if (evictRecheck.unref) evictRecheck.unref()
1670
+ }
1532
1671
  function evictIfOverCap(keep) {
1533
1672
  if (fileDocs.size <= maxOpen()) return
1534
1673
  const now = Date.now()
@@ -1540,14 +1679,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1540
1679
  try { if (e.conflict && e.conflict.size) continue } catch { }
1541
1680
  if (isClaimed(r)) continue
1542
1681
  const t = touched.get(r) || 0
1543
- if (now - t < EVICT_GRACE_MS) continue
1682
+ const landedNew = arrivedNew.has(r) && fs.existsSync(path.join(ROOT, r))
1683
+ if (now - t < EVICT_GRACE_MS && !landedNew) continue
1544
1684
  cand.push([r, t])
1545
1685
  }
1546
1686
  cand.sort((a, b) => a[1] - b[1]) // least recently used first
1547
1687
  let over = fileDocs.size - maxOpen()
1548
1688
  for (const [r] of cand) {
1549
1689
  if (over-- <= 0) break
1550
- closeFile(r); touched.delete(r); evicted++
1690
+ closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
1551
1691
  }
1552
1692
  }
1553
1693
 
@@ -1572,7 +1712,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1572
1712
  let over = fileDocs.size - cap
1573
1713
  for (const [r] of cand) {
1574
1714
  if (over-- <= 0) break
1575
- closeFile(r); touched.delete(r); evicted++
1715
+ closeFile(r); touched.delete(r); arrivedNew.delete(r); evicted++
1576
1716
  }
1577
1717
  }
1578
1718
 
@@ -2263,6 +2403,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2263
2403
  }
2264
2404
  if (!fe.synced && origin !== 'local') return
2265
2405
  touchFile(relPath)
2406
+ if (origin !== 'remote') arrivedNew.delete(relPath) // a local change: an ordinary file now (see reconcile)
2266
2407
 
2267
2408
  if (origin === 'local') {
2268
2409
  if (!canWrite(relPath)) {
@@ -2439,6 +2580,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2439
2580
  else if (withheld.has(relPath)) clearWithheld(relPath)
2440
2581
  }
2441
2582
  touchFile(relPath) // reconciling a file IS using it — keep it in the working set
2583
+ // ...but only a LOCAL change makes a just-arrived new file an ordinary one with the full grace.
2584
+ // A remote reconcile is the arrival itself, and must not take away its early eviction.
2585
+ if (origin !== 'remote') arrivedNew.delete(relPath)
2442
2586
  // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
2443
2587
  // this file, record any authored edit sitting on disk. The edit enters the provenance
2444
2588
  // DAG first; whatever happens to the disk afterwards can no longer lose it.
@@ -2878,7 +3022,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2878
3022
  icrWarned.set(relPath, why)
2879
3023
  log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
2880
3024
  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.`)
3025
+ 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
3026
  } catch { /* the log still carries it */ }
2883
3027
  }
2884
3028
  return
@@ -2924,7 +3068,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2924
3068
  if (hasMarkers && !conflicted.has(relPath)) {
2925
3069
  conflicted.add(relPath)
2926
3070
  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 { }
3071
+ 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
3072
  } else if (!hasMarkers && conflicted.has(relPath)) {
2929
3073
  conflicted.delete(relPath)
2930
3074
  try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
@@ -4027,26 +4171,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4027
4171
  const key = openQueue.shift()
4028
4172
  openQueueSet.delete(key)
4029
4173
  if (fileDocs.size >= maxOpen()) {
4030
- openQueue.length = 0
4031
- openQueueSet.clear()
4032
- break
4174
+ // At the bound a key from the join sweep waits for a touch, as before -- but a NEW file is
4175
+ // never dropped: it opens within the headroom, or goes back to the head of the queue for
4176
+ // the re-check (scheduleEvictRecheck) to let in once room is made.
4177
+ if (!newRemote.has(key)) continue
4178
+ if (fileDocs.size >= maxOpen() + NEW_HEADROOM) {
4179
+ openQueue.unshift(key); openQueueSet.add(key)
4180
+ scheduleEvictRecheck()
4181
+ break
4182
+ }
4033
4183
  }
4034
4184
  // STILL WANTED? A key can wait here while a peer deletes the file. Its document still exists on
4035
4185
  // the relay, so opening it would bring the deleted file back to this disk and publish it again
4036
4186
  // for everyone. Wanted = still in the room, or on this disk (a new local file not yet published).
4037
4187
  const wanted = inManifest(key) || fs.existsSync(path.join(ROOT, key))
4188
+ if (!wanted) newRemote.delete(key)
4038
4189
  if (wanted && canOpen(key) && !SKIP.has(key) && !isIgnored(key) && !fileDocs.has(key)) {
4039
4190
  openFile(key)
4040
4191
  openedCount++
4041
4192
  }
4042
4193
  }
4043
- if (openQueue.length && !stopped && fileDocs.size < maxOpen()) {
4194
+ if (openQueue.length && !stopped && (fileDocs.size < maxOpen() || (newRemote.size && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
4044
4195
  openTimer = setImmediate(drainOpenQueue)
4045
4196
  }
4046
4197
  }
4047
4198
 
4048
4199
  function queueOpenFile(key) {
4049
- if (fileDocs.has(key) || fileDocs.size >= maxOpen() || openQueueSet.has(key)) return
4200
+ if (fileDocs.has(key) || (fileDocs.size >= maxOpen() && !newRemote.has(key)) || openQueueSet.has(key)) return
4050
4201
  openQueueSet.add(key)
4051
4202
  if (fs.existsSync(path.join(ROOT, key))) openQueue.unshift(key)
4052
4203
  else openQueue.push(key)
@@ -4094,6 +4245,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4094
4245
  }
4095
4246
  }
4096
4247
  pendingManifestOps.delete(key)
4248
+ newRemote.delete(key); arrivedNew.delete(key) // deleted while new or queued: nothing to wait for
4097
4249
  closeFile(key)
4098
4250
  { const qi = openQueue.indexOf(key); if (qi >= 0) { openQueue.splice(qi, 1); openQueueSet.delete(key) } } // deleted while queued
4099
4251
  if (!stamp) stamp = deletedStamp() // one folder per batch, so a bulk delete comes back together
@@ -4109,7 +4261,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4109
4261
  for (const [key, change] of others) {
4110
4262
  if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
4111
4263
  checkCaseCollision(key)
4112
- if (ev.changes.keys.size <= 2 && fileDocs.size < maxOpen()) {
4264
+ // NEW = created after this client joined (roomAtJoin is the room at the first sync) and not
4265
+ // on this disk. The join's own manifest arrives before roomAtJoin exists, so it is never new.
4266
+ const isNew = !!roomAtJoin && change.action === 'add' && !roomAtJoin.has(key) && !fs.existsSync(path.join(ROOT, key))
4267
+ if (isNew) newRemote.add(key)
4268
+ if (ev.changes.keys.size <= 2 && (fileDocs.size < maxOpen() || (isNew && fileDocs.size < maxOpen() + NEW_HEADROOM))) {
4113
4269
  openFile(key)
4114
4270
  } else {
4115
4271
  queueOpenFile(key)
@@ -4821,6 +4977,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4821
4977
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
4822
4978
  resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
4823
4979
  edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
4980
+ editLines, // hive_edit old/new: whole lines found once in the room's copy, which is then the base
4824
4981
  // --- non-writer review & approval ---
4825
4982
  review,
4826
4983
  reviews: getReviews,
@@ -4952,6 +5109,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
4952
5109
  // how this room actually works — both are things an operator can only act on if
4953
5110
  // they can see them.
4954
5111
  openCap: maxOpen(),
5112
+ newWaiting: newRemote.size,
4955
5113
  // 'mux' = every file on this client's one socket; 'socket' = a socket per file.
4956
5114
  transport: fileTransport.mode,
4957
5115
  evicted,