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.
- package/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2384 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +1 -1
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +169 -11
- 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
|
-
|
|
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
|
|
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
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
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
|
-
|
|
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,
|