hivecode-mcp 0.14.3 → 0.14.5
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 +41 -98
- package/core.js +2 -0
- package/docs/TROUBLESHOOTING.md +3 -0
- package/hive-await.js +57 -21
- package/hive-gate.js +116 -2
- package/hive-instructions.js +110 -61
- package/hive-mailbox.js +74 -7
- package/hive-mcp.js +200 -42
- package/hive-notify.js +21 -5
- package/hive-receipts.js +56 -5
- package/hive-room-mode.js +13 -0
- package/hive-room-watcher.js +30 -12
- package/hive-seen.js +135 -0
- package/hive-slack.js +27 -2
- package/hive-stop-hook.js +33 -18
- package/hive-transport.js +39 -4
- package/icr-merge.js +8 -1
- package/icr.js +3 -1
- package/package.json +3 -2
- package/reconcile.cjs +123 -21
- package/sync.js +684 -203
- package/docs/HIVE_RULES.md +0 -41
package/sync.js
CHANGED
|
@@ -24,10 +24,11 @@ import { WebsocketProvider } from 'y-websocket'
|
|
|
24
24
|
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
25
25
|
import { HiveSocket, transportFor } from './hive-transport.js' // a WebSocket, or HTTPS long-poll where WebSockets are refused
|
|
26
26
|
import ignore from 'ignore'
|
|
27
|
-
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
27
|
+
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3, lineHunks, blockVersions } from './core.js'
|
|
28
28
|
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
29
29
|
import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
|
|
30
|
-
import {
|
|
30
|
+
import { RULES_MD, AGENT_MANUAL } from './hive-instructions.js'
|
|
31
|
+
import { gateConfig, runGate, verdictRecord, refusalMessage, forcedMessage, gateApproved, gatesDir, writeApproval, notApprovedMessage, notApprovedLine, GATE_CMD, GATE_NOT_APPROVED } from './hive-gate.js' // the HANDOFF gate: a claim you cannot give back while your own check fails
|
|
31
32
|
import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
|
|
32
33
|
import { validateMemberName } from './hive-mailbox.js' // a name the mention parser cannot produce is not a name
|
|
33
34
|
import { genIdentity, authorChange, verifyReceipt, contentHash } from './substrate.js' // convergent-substrate: signed provenance for every landed change
|
|
@@ -167,6 +168,222 @@ export function fusionReason(merged, mine, theirs, lang) {
|
|
|
167
168
|
|
|
168
169
|
const endNl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
169
170
|
|
|
171
|
+
// SAME-LINE RACE (P0-F, docs/rules-rewrite/p0f-design.md). Two saves of one line made before either
|
|
172
|
+
// client saw the other reach the shared Y.Text as concurrent character edits, and the CRDT keeps both:
|
|
173
|
+
// "ship friday" saved as "ship monday" and "ship tuesday" became "ship montuesday" in every folder, and
|
|
174
|
+
// the same change twice became "ship monmonday". Every version from 0.6.0 to 0.14.3 did it. So each save
|
|
175
|
+
// also records its INTENT -- the line hunks it changed and a hash of the text it was made from -- in the
|
|
176
|
+
// same transaction, and these pure functions decide from the intents what the file should say.
|
|
177
|
+
//
|
|
178
|
+
// An intent: { b: contentHash(base), h: [[baseStart, baseEnd, newLines]...] }. Null when the diff is too
|
|
179
|
+
// big to describe (lineHunks gave up) or the hunks are larger than INTENT_MAX_LINES: no detection then,
|
|
180
|
+
// exactly as before.
|
|
181
|
+
const INTENT_MAX_LINES = 400
|
|
182
|
+
export function intentHunks(base, next) {
|
|
183
|
+
const a = base.split('\n'), b = next.split('\n')
|
|
184
|
+
const hs = lineHunks(a, b)
|
|
185
|
+
if (!hs) return null
|
|
186
|
+
let n = 0
|
|
187
|
+
const out = hs.map(([bs, be, ss, se]) => { n += se - ss; return [bs, be, b.slice(ss, se)] })
|
|
188
|
+
return n > INTENT_MAX_LINES ? null : out
|
|
189
|
+
}
|
|
190
|
+
// The base text with one intent's hunks applied: that author's whole version.
|
|
191
|
+
export function applyIntent(base, h) {
|
|
192
|
+
const lines = base.split('\n')
|
|
193
|
+
for (const [bs, be, nl] of [...h].sort((x, y) => y[0] - x[0])) lines.splice(bs, be - bs, ...nl)
|
|
194
|
+
return lines.join('\n')
|
|
195
|
+
}
|
|
196
|
+
// Can two saves made from the same text damage each other in the character CRDT? (Opus-Cloud's rule,
|
|
197
|
+
// fuzzed at 0 bad over about 78k pairs.) applyDiff sends ONE character span per save, from its first
|
|
198
|
+
// changed line to its last, so a save's footprint is [first hunk start, last hunk end), not its hunks:
|
|
199
|
+
// A editing lines 1 and 5 while B edits line 3 lost B's line under a per-hunk rule (8,439 of 49,748
|
|
200
|
+
// "no clash" pairs fused or lost an edit). Footprints that overlap clash. Footprints that only touch
|
|
201
|
+
// clash too, because a pure insertion or deletion slides along repeated text into its neighbour, except
|
|
202
|
+
// two same-size line replacements side by side: that is the W1 shape, and the CRDT merges it correctly
|
|
203
|
+
// (0 of 915 fused), so it must not become a conflict as the fork gate makes it.
|
|
204
|
+
const footprint = (h) => (h.length ? [h[0][0], h[h.length - 1][1], h.length === 1 ? h[0][2] : null] : null)
|
|
205
|
+
export function hunksClash(h1, h2) {
|
|
206
|
+
const a = footprint(h1), b = footprint(h2)
|
|
207
|
+
if (!a || !b) return false
|
|
208
|
+
if (Math.max(a[0], b[0]) < Math.min(a[1], b[1])) return true
|
|
209
|
+
const rep = (f) => f[2] !== null && f[1] > f[0] && f[1] - f[0] === f[2].length
|
|
210
|
+
const touch = a[1] === b[0] || b[1] === a[0] || (a[0] === a[1] && a[0] >= b[0] && a[0] <= b[1]) || (b[0] === b[1] && b[0] >= a[0] && b[0] <= a[1])
|
|
211
|
+
return touch && !(rep(a) && rep(b))
|
|
212
|
+
}
|
|
213
|
+
// Do two saves change the same base LINE? The finer question, asked inside a clash: when no two saves
|
|
214
|
+
// do, they can all be applied cleanly and the result needs no markers.
|
|
215
|
+
export function linesOverlap(h1, h2) {
|
|
216
|
+
for (const [a0, a1] of h1) for (const [b0, b1] of h2) {
|
|
217
|
+
if (Math.max(a0, b0) < Math.min(a1, b1)) return true
|
|
218
|
+
if (a0 === a1 && b0 < a0 && a0 < b1) return true
|
|
219
|
+
if (b0 === b1 && a0 < b0 && b0 < a1) return true
|
|
220
|
+
}
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
// Two hunks share a base line: spans that overlap, or a line inserted strictly inside the other's span.
|
|
224
|
+
function spansMeet([a0, a1], [b0, b1]) {
|
|
225
|
+
if (Math.max(a0, b0) < Math.min(a1, b1)) return true
|
|
226
|
+
if (a0 === a1 && b0 < a0 && a0 < b1) return true
|
|
227
|
+
if (b0 === b1 && a0 < b0 && b0 < a1) return true
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
// The clash region and everything around it, for intents (Map clientID -> h) all made from `base`.
|
|
231
|
+
// [rs, re) spans every base line that two intents both changed. Outside it the intents' hunks are
|
|
232
|
+
// disjoint, so the lines before and after are the base's with those hunks applied, the same for every
|
|
233
|
+
// author; inside it each author has their own version. Null when nothing clashes.
|
|
234
|
+
export function clashRegion(base, intents) {
|
|
235
|
+
let rs = Infinity, re = -Infinity
|
|
236
|
+
const all = [...intents.values()]
|
|
237
|
+
for (let i = 0; i < all.length; i++) for (let j = 0; j < all.length; j++) {
|
|
238
|
+
if (i === j) continue
|
|
239
|
+
for (const hi of all[i]) if (hunksClash([hi], all[j])) { rs = Math.min(rs, hi[0]); re = Math.max(re, hi[1]) }
|
|
240
|
+
}
|
|
241
|
+
if (rs === Infinity) return null
|
|
242
|
+
const b = base.split('\n')
|
|
243
|
+
// Before the region, inside it, or after. An insertion AT either edge is inside: it touches the clash.
|
|
244
|
+
const where = ([bs, be]) => (be < rs || (be === rs && bs < rs) ? 'pre' : bs > re || (bs === re && be > re) ? 'post' : 'in')
|
|
245
|
+
const hs = tagged(intents)
|
|
246
|
+
const pre = applyTagged(b.slice(0, rs), hs.filter((x) => where(x) === 'pre'))
|
|
247
|
+
const post = applyTagged(b.slice(re), hs.filter((x) => where(x) === 'post'), re)
|
|
248
|
+
const inside = hs.filter((x) => where(x) === 'in')
|
|
249
|
+
const sides = new Map()
|
|
250
|
+
for (const id of [...intents.keys()].sort((x, y) => x - y)) sides.set(id, applyTagged(b.slice(rs, re), inside.filter((x) => x[3] === id), rs))
|
|
251
|
+
return { rs, re, pre, post, sides, inside }
|
|
252
|
+
}
|
|
253
|
+
// Every hunk tagged with its author, in the one order every peer applies them: from the bottom up; at one
|
|
254
|
+
// start line a span before an insertion there (inserting first let the span delete the inserted line);
|
|
255
|
+
// insertions at one point higher client first, so the lower client's lines come first. Never arrival order.
|
|
256
|
+
function tagged(intents) {
|
|
257
|
+
return [...intents].flatMap(([id, h]) => h.map((x) => [x[0], x[1], x[2], id])).sort((x, y) => y[0] - x[0] || (y[1] - y[0]) - (x[1] - x[0]) || y[3] - x[3])
|
|
258
|
+
}
|
|
259
|
+
function applyTagged(lines, hs, off = 0) { for (const [bs, be, nl] of hs) lines.splice(bs - off, be - bs, ...nl); return lines }
|
|
260
|
+
// What the file should say once these intents (every save made from `base` at that moment) are folded in.
|
|
261
|
+
// Every peer computes the same text:
|
|
262
|
+
// - no two authors changed the same line: every change applied cleanly (the merge the CRDT got wrong);
|
|
263
|
+
// - otherwise one marker block over the region where they did, holding each distinct version once, in
|
|
264
|
+
// client order; an author who left that region as it was is not a version. Changes elsewhere, by
|
|
265
|
+
// anyone, are applied around the block. (merge3 cannot fold a third version in: it strips existing
|
|
266
|
+
// markers so blocks never nest, and the third author's line was lost that way, R6.)
|
|
267
|
+
export function clashFinal(base, intents, names = new Map()) {
|
|
268
|
+
const r = clashRegion(base, intents)
|
|
269
|
+
if (!r) return applyTagged(base.split('\n'), tagged(intents)).join('\n')
|
|
270
|
+
const reg = base.split('\n').slice(r.rs, r.re)
|
|
271
|
+
const changed = [...r.sides].filter(([, l]) => J(l) !== J(reg))
|
|
272
|
+
const meet = r.inside.some((p, i) => r.inside.some((q, j) => j > i && p[3] !== q[3] && J(p.slice(0, 3)) !== J(q.slice(0, 3)) && spansMeet(p, q)))
|
|
273
|
+
let mid
|
|
274
|
+
if (!changed.length) mid = reg
|
|
275
|
+
else if (!meet) {
|
|
276
|
+
const seen = new Set()
|
|
277
|
+
mid = applyTagged(reg.slice(), r.inside.filter((x) => { const k = J(x.slice(0, 3)); if (seen.has(k)) return false; seen.add(k); return true }), r.rs)
|
|
278
|
+
} else {
|
|
279
|
+
const distinct = []
|
|
280
|
+
for (const [id, lines] of changed) if (!distinct.some((d) => J(d.lines) === J(lines))) distinct.push({ id, lines })
|
|
281
|
+
const label = (id) => String(names.get(id) || `client ${id}`).replace(/[\r\n]/g, ' ')
|
|
282
|
+
if (distinct.length === 1) mid = distinct[0].lines
|
|
283
|
+
else if (distinct.length === 2) mid = [`<<<<<<< ${label(distinct[0].id)}`, ...distinct[0].lines, '=======', ...distinct[1].lines, `>>>>>>> ${label(distinct[1].id)}`]
|
|
284
|
+
else {
|
|
285
|
+
// Plain separators: a labelled one ("======= Bob") is not one any merge reads, and a late save
|
|
286
|
+
// merged against such a block lost every version in it (late-save-merge-test L2).
|
|
287
|
+
mid = [`<<<<<<< ${label(distinct[0].id)}`, ...distinct[0].lines]
|
|
288
|
+
for (const d of distinct.slice(1)) mid.push('=======', ...d.lines)
|
|
289
|
+
mid.push(`>>>>>>> ${distinct.slice(1).map((d) => label(d.id)).join(', ')} (${distinct.length} versions)`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return [...r.pre, ...mid, ...r.post].join('\n')
|
|
293
|
+
}
|
|
294
|
+
const J = (x) => JSON.stringify(x)
|
|
295
|
+
// The text every folder holds once these saves (Map clientID -> hunks, all made from `base`) have crossed:
|
|
296
|
+
// their character union. It depends only on the base text, each author's text and their client ids, not on
|
|
297
|
+
// the history that built the base (tests/same-line-intent-unit-test.mjs, CU rows), so every peer can
|
|
298
|
+
// rebuild it exactly from the intents alone.
|
|
299
|
+
export function clashUnion(base, intents, laters = []) {
|
|
300
|
+
const root = new Y.Doc(); root.clientID = 0; root.getText('t').insert(0, base)
|
|
301
|
+
const seed = Y.encodeStateAsUpdate(root)
|
|
302
|
+
const docs = new Map()
|
|
303
|
+
for (const [id, h] of [...intents].sort((x, y) => x[0] - y[0])) { const d = new Y.Doc(); d.clientID = id; Y.applyUpdate(d, seed); applyDiff(d.getText('t'), applyIntent(base, h)); docs.set(id, d) }
|
|
304
|
+
// A later save (F5): made by `id` on a text that already held the saves in `after`, so its doc had their
|
|
305
|
+
// updates (and its own earlier ones, if `id` saved before) when it applied its hunks.
|
|
306
|
+
for (const L of laters) {
|
|
307
|
+
let d = docs.get(L.id)
|
|
308
|
+
if (!d) { d = new Y.Doc(); d.clientID = L.id; Y.applyUpdate(d, seed); docs.set(L.id, d) }
|
|
309
|
+
for (const a of L.after) if (a !== L.id && docs.has(a)) Y.applyUpdate(d, Y.encodeStateAsUpdate(docs.get(a)))
|
|
310
|
+
const t = d.getText('t')
|
|
311
|
+
applyDiff(t, applyIntent(t.toString(), L.h))
|
|
312
|
+
}
|
|
313
|
+
const all = [...docs.values()]
|
|
314
|
+
if (!all.length) { root.destroy(); return base }
|
|
315
|
+
const ups = all.map((d) => Y.encodeStateAsUpdate(d))
|
|
316
|
+
for (const u of ups) Y.applyUpdate(all[0], u)
|
|
317
|
+
const out = all[0].getText('t').toString()
|
|
318
|
+
for (const d of all) d.destroy(); root.destroy()
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
// F5: which text was a later save made from? A save that arrives while a race from `base` is pending, but
|
|
322
|
+
// was made from another text, was usually made from what its author held: one side of the race, or the
|
|
323
|
+
// clean merge of several sides that do not clash. Returns { after, text } -- the saves that text held and
|
|
324
|
+
// the text itself -- when `bHash` is the hash of such a text, else null. Never a FUSED union: a version made
|
|
325
|
+
// from fused text would carry the fused line into the resolution as if someone had written it.
|
|
326
|
+
// A SAME-LINE SAVE THAT ARRIVES AFTER THE RACE WAS RESOLVED (P0-F-12). `base` is the text the save was made
|
|
327
|
+
// from and `side` the author's file. The save reached the shared text as a character edit made against
|
|
328
|
+
// `base`, but the room had already replaced that line with a conflict block, so the CRDT put what the save
|
|
329
|
+
// inserted next to the characters it was anchored on, which the block had deleted: `wednes<<<<<<< Bob`.
|
|
330
|
+
// Returns the room's text before the save -- `current` with the save's inserted text taken out -- when
|
|
331
|
+
// that is a well-formed block whose versions are other edits of the lines this save changed. Null
|
|
332
|
+
// otherwise: not this shape, so not ours to repair.
|
|
333
|
+
export function lateOverBlock(base, side, current) {
|
|
334
|
+
let p = 0
|
|
335
|
+
while (p < base.length && p < side.length && base[p] === side[p]) p++
|
|
336
|
+
let s = 0
|
|
337
|
+
while (s < base.length - p && s < side.length - p && base[base.length - 1 - s] === side[side.length - 1 - s]) s++
|
|
338
|
+
const ins = side.slice(p, side.length - s)
|
|
339
|
+
// (Not hasConflictMarkers: the opener this repairs no longer starts its line.)
|
|
340
|
+
if (!ins || !/<<<<<<< |>>>>>>> /.test(current) || current.includes(side)) return null
|
|
341
|
+
// Already a version of a block in the room: nothing to repair.
|
|
342
|
+
const cv = blockVersions(current)
|
|
343
|
+
if (cv && cv.versions.includes(side)) return null
|
|
344
|
+
const mine = intentHunks(base, side)
|
|
345
|
+
if (!mine || !mine.length) return null
|
|
346
|
+
const lo = mine[0][0], hi = mine[mine.length - 1][1]
|
|
347
|
+
for (let i = current.indexOf(ins); i >= 0; i = current.indexOf(ins, i + 1)) {
|
|
348
|
+
// The CRDT can only have put it where its anchor characters were, and the block deleted those: at an
|
|
349
|
+
// edge of the block, just before its opener or at the end of its closing line. Anywhere else (inside a
|
|
350
|
+
// version, say) it is someone's text, not a stray.
|
|
351
|
+
const before = current.slice(0, i), after = current.slice(i + ins.length)
|
|
352
|
+
const lineStart = before.lastIndexOf('\n') + 1
|
|
353
|
+
const atOpen = after.startsWith('<<<<<<< ') && lineStart === i
|
|
354
|
+
const atClose = /^>>>>>>> /.test(current.slice(lineStart, i)) && (after === '' || after[0] === '\n')
|
|
355
|
+
if (!atOpen && !atClose) continue
|
|
356
|
+
const x = before + after
|
|
357
|
+
if (!hasConflictMarkers(x) || /[^\n](<<<<<<< |>>>>>>> )/.test(x)) continue
|
|
358
|
+
const bv = blockVersions(x)
|
|
359
|
+
if (!bv || bv.versions.length < 2) continue
|
|
360
|
+
// Every version the block holds is an edit of this save's lines, from `base`. It may differ from
|
|
361
|
+
// `base` elsewhere too: someone changed another line since (a far line, in same-line-soak-test seed
|
|
362
|
+
// 3, and the repair was refused). The late-save merge carries that change into every version.
|
|
363
|
+
const ok = bv.versions.every((v) => {
|
|
364
|
+
const h = intentHunks(base, v)
|
|
365
|
+
return h && h.some(([bs, be]) => be >= lo && bs <= hi)
|
|
366
|
+
})
|
|
367
|
+
if (ok) return x
|
|
368
|
+
}
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
export function explainBase(base, firsts, bHash) {
|
|
372
|
+
const ids = [...firsts.keys()].sort((x, y) => x - y).slice(0, 6)
|
|
373
|
+
const pick = (k, from = 0) => (k === 0 ? [[]] : ids.slice(from).flatMap((id, i) => pick(k - 1, from + i + 1).map((r) => [id, ...r])))
|
|
374
|
+
for (let k = 1; k <= ids.length; k++) for (const sub of pick(k)) {
|
|
375
|
+
const m = new Map(sub.map((i) => [i, firsts.get(i)]))
|
|
376
|
+
const t = clashUnion(base, m)
|
|
377
|
+
if (contentHash(t) === bHash && t === clashFinal(base, m)) return { after: sub, text: t }
|
|
378
|
+
}
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
export function clashOnlyRegion(base, intents, final, current, laters = []) {
|
|
382
|
+
if (final === current) return true
|
|
383
|
+
if (!clashRegion(base, intents)) return false
|
|
384
|
+
return current === clashUnion(base, intents, laters)
|
|
385
|
+
}
|
|
386
|
+
|
|
170
387
|
// HIV-P17. Why a creation race of relPath must NOT land, '' when it may: 'CONFLICT' when two whole
|
|
171
388
|
// files collide in a file with a language, else fusionReason's answer when a clean-looking union
|
|
172
389
|
// broke code both sides could parse. No language (Markdown) is never refused.
|
|
@@ -229,149 +446,12 @@ const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-
|
|
|
229
446
|
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
230
447
|
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
231
448
|
// .hive.json already in the folder, so no human has to paste a link.
|
|
232
|
-
const AGENT_MANUAL_TEXT =
|
|
233
|
-
|
|
234
|
-
This project is part of a live Hivecode room: humans and AI agents editing the same
|
|
235
|
-
codebase together, in real time, with no git push/pull. If you are an AI assistant
|
|
236
|
-
asked to collaborate here, follow this.
|
|
237
|
-
|
|
238
|
-
## Join the room
|
|
239
|
-
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
240
|
-
the Hivecode MCP server, then reload so the tools load:
|
|
241
|
-
claude mcp add hivecode -- npx -y hivecode-mcp@latest
|
|
242
|
-
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
243
|
-
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
244
|
-
do NOT need a link — hive_join reads them automatically.
|
|
245
|
-
3. Read the HIVE_RULES it returns, and follow them.
|
|
246
|
-
|
|
247
|
-
## Work the loop
|
|
248
|
-
- hive_read_chat() and hive_read_board() BEFORE editing — see what others are doing.
|
|
249
|
-
- hive_claim("path/to/file") BEFORE you edit a file. If you get it, edit then
|
|
250
|
-
hive_release it. If you DON'T, someone else holds it — pick other work
|
|
251
|
-
(hive_claims() shows what's open). This is how agents avoid colliding.
|
|
252
|
-
- hive_say("taking X: doing Y") to announce intent before you edit.
|
|
253
|
-
- ADDRESS EVERY MESSAGE. Put @Name on it for each agent it is for -- hive_say("@Ana I need the churn numbers before I touch pricing.md"). Any agent may address any other agent directly; there is no lead to route through and nobody needs permission. A message that names nobody lands in every agent's "room activity", where they are told to skip it, so it will most likely be ignored. Use @all when you genuinely need the whole room -- it wakes everyone, so spending it on chatter is how a room learns to ignore you.
|
|
254
|
-
- ANSWER EVERY QUESTION AIMED AT YOU. "Not yet -- I am on X, I will come back to you" is a complete answer; silence is the one thing the asker cannot act on, because nothing tells them you stopped. hive_check lists anyone still waiting on you.
|
|
255
|
-
- BEFORE A LONG STRETCH OF WORK, call hive_busy("what you are doing"). It posts no message and silences nothing -- it tells anyone who @mentions you, at the moment they send, that you are mid-task rather than ignoring them. hive_busy(clear:true) when you finish.
|
|
256
|
-
- Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
|
|
257
|
-
- Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
|
|
258
|
-
- A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
|
|
259
|
-
|
|
260
|
-
## Rules of the road
|
|
261
|
-
- Stay in your lane — never edit folders you weren't granted.
|
|
262
|
-
- Announce before editing; coordinate in chat; ask before anything destructive in others' areas.
|
|
263
|
-
- If a human PAUSES you, finish your current step and stop until resumed.
|
|
264
|
-
- Resolve <<<<<<< conflict markers properly — never blindly overwrite.
|
|
265
|
-
- A human can ROLL BACK your changes at any time; that's normal — re-read and continue.
|
|
266
|
-
|
|
267
|
-
## If something breaks
|
|
268
|
-
Support is support@getgrm.tech. Mention it only if the human asks, or if something
|
|
269
|
-
here is genuinely broken and they need somewhere to send it. Never volunteer it
|
|
270
|
-
otherwise, and never write it into a file.
|
|
271
|
-
`
|
|
449
|
+
const AGENT_MANUAL_TEXT = AGENT_MANUAL // hive-instructions.js: one source for every agent-facing text (0.14.4)
|
|
272
450
|
|
|
273
451
|
// The hive's law. Auto-written into every room folder so it is ALWAYS present —
|
|
274
452
|
// no setup, no relying on an agent to remember it. The sync layer enforces the
|
|
275
453
|
// hard parts (merge, board); this is the rest, in plain imperative language.
|
|
276
|
-
const HIVE_RULES_TEXT =
|
|
277
|
-
|
|
278
|
-
You are in a Hivecode room: a shared live workspace where humans and AI agents
|
|
279
|
-
edit ONE project together in real time. These rules keep anyone from destroying
|
|
280
|
-
another's work. The sync layer enforces the hard parts automatically; you do the rest.
|
|
281
|
-
|
|
282
|
-
## Identity
|
|
283
|
-
- You are automatically "human" (you ran the editor) or "ai" (you ran the agent
|
|
284
|
-
client). Nobody declares it — running the client is the declaration.
|
|
285
|
-
|
|
286
|
-
## Know who you are working with
|
|
287
|
-
- Read HIVE_MEMBERS.md — who (humans + AIs) is in the room right now, and how many.
|
|
288
|
-
|
|
289
|
-
## DESIGN BEFORE YOU WRITE — mandatory (Ganesh, 19 Sep 2026)
|
|
290
|
-
Before you build any new feature, post in the room, BEFORE your first edit:
|
|
291
|
-
1. WHO uses it, and when — name the person or agent and the moment they reach for it.
|
|
292
|
-
2. WHAT IT COSTS when it is wrong — one sentence someone who did not write it can act on at 3am.
|
|
293
|
-
3. EVERY EDGE CASE you can name — empty, missing, offline, refused, concurrent, already-done,
|
|
294
|
-
called twice, called by the wrong person.
|
|
295
|
-
Then build it.
|
|
296
|
-
This is not process for its own sake. Every defect this project has produced has been a FALSE
|
|
297
|
-
SUCCESS — nothing crashed, every signal was green, the result was wrong. That class is never
|
|
298
|
-
caught by testing the happy path afterwards; it is caught by asking first what "wrong but green"
|
|
299
|
-
would look like for this particular feature. If you cannot say what breaks, you do not understand
|
|
300
|
-
it well enough to write it — say so in the room and ask. That is a complete answer.
|
|
301
|
-
|
|
302
|
-
## Before you touch a file
|
|
303
|
-
1. Read HIVE_CHAT.md — what is everyone doing right now.
|
|
304
|
-
2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
|
|
305
|
-
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
306
|
-
|
|
307
|
-
## CLAIM before you edit (this is how the hive avoids collisions)
|
|
308
|
-
- Before editing a file, CLAIM it: call the hive_claim tool with the file path
|
|
309
|
-
(and a short intent). This is the most important coordination step.
|
|
310
|
-
• If you GOT it: edit it, then call hive_release when you finish.
|
|
311
|
-
• If you could NOT get it: someone else holds it — do NOT edit it. Pick other
|
|
312
|
-
work; call hive_claims to see what's open.
|
|
313
|
-
- Claims auto-expire, so a stalled agent never blocks the hive — but always
|
|
314
|
-
hive_release the moment you finish, so others can take it.
|
|
315
|
-
|
|
316
|
-
## While you work
|
|
317
|
-
4. ANNOUNCE first: post to chat what you are taking, e.g.
|
|
318
|
-
call hive_say with "taking auth.js: adding login validation"
|
|
319
|
-
5. PREFER SMALL PATCHES — grep to the spot, edit a few lines. Patches from
|
|
320
|
-
different agents merge automatically with no conflict.
|
|
321
|
-
6. AVOID full-file rewrites unless necessary. If you must rewrite, RE-READ the
|
|
322
|
-
file first so you build on the latest code (rewrites are auto-logged for all).
|
|
323
|
-
7. STAY IN YOUR LANE — if someone said "I own X" (e.g. the backend), read X but
|
|
324
|
-
do not edit it; leave that to the owner.
|
|
325
|
-
|
|
326
|
-
## When things collide
|
|
327
|
-
8. If you see <<<<<<< ======= >>>>>>> markers in a file, the system could not
|
|
328
|
-
auto-merge — RESOLVE it: keep the right code, delete the markers. Never ignore
|
|
329
|
-
them or blindly overwrite.
|
|
330
|
-
9. If your edit was merged/reworked, that is normal — re-read and continue.
|
|
331
|
-
|
|
332
|
-
## Talking
|
|
333
|
-
10. Coordinate in chat. ASK before anything destructive (delete, rename, big
|
|
334
|
-
refactor) that touches another participant's area.
|
|
335
|
-
|
|
336
|
-
## Directed work + permission (the asymmetric gate)
|
|
337
|
-
11. AI -> AI is COORDINATION: if another agent hands you work (via hive_assign), it is auto-accepted
|
|
338
|
-
and you may proceed. If there is no Lead PM setting global state, use hive_assign and hive_say
|
|
339
|
-
to assign tasks directly to other agents to divide and conquer. This is how a leaderless hive
|
|
340
|
-
plans and divides work by itself.
|
|
341
|
-
12. A HUMAN directing you is different. If a human who is NOT your owner assigns
|
|
342
|
-
you a task, you do NOT act on it — it stays PENDING until YOUR OWNER approves
|
|
343
|
-
("do it or ignore?"). Your own owner's requests proceed. Act only on tasks
|
|
344
|
-
whose status is 'accepted'.
|
|
345
|
-
13. If you are an MCP agent: loop on hive_wait — it blocks until accepted work
|
|
346
|
-
arrives, then you do it and call hive_complete. No need to poll. A pending
|
|
347
|
-
human request will NOT wake you until your owner approves it.
|
|
348
|
-
|
|
349
|
-
## Mission control (a human can pause/steer you)
|
|
350
|
-
- If you are PAUSED, finish your current step and STOP — do not start new work
|
|
351
|
-
until you are resumed. (MCP agents: hive_wait returns no work while paused.)
|
|
352
|
-
- A human may REASSIGN your focus at any time; treat a fresh directive from your
|
|
353
|
-
owner as the new priority.
|
|
354
|
-
|
|
355
|
-
## When a ping arrives while you are mid-task (interruptions)
|
|
356
|
-
14. hive_wait only checks BETWEEN steps, so a ping never interrupts mid-step.
|
|
357
|
-
Finish your current atomic step first (never abandon half-done work), then
|
|
358
|
-
handle queued coordination. If it's urgent (build broken, blocking others),
|
|
359
|
-
do it now.
|
|
360
|
-
15. ACKNOWLEDGE a ping as soon as you see it so the sender isn't left hanging,
|
|
361
|
-
e.g. say "got it — finishing X (~2 min), then on your fix".
|
|
362
|
-
16. YOU triage: announce do-now vs after-current. Your OWNER can override anytime
|
|
363
|
-
— if your owner says "do it now" or "skip that", that wins.
|
|
364
|
-
|
|
365
|
-
## State-Machine Prompting (SMP)
|
|
366
|
-
17. The Lead PM controls the [GLOBAL PROJECT STATE] via hive_set_state.
|
|
367
|
-
18. ONLY use short enum flags (e.g. "APPROVED", "LOCKED"). Do NOT store paragraphs of text in the state, it will bloat the context window.
|
|
368
|
-
19. If you are a worker agent, block on hive_wait until your specific state flag turns to APPROVED/READY. Do not write code if you are LOCKED.
|
|
369
|
-
|
|
370
|
-
Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
|
|
371
|
-
|
|
372
|
-
---
|
|
373
|
-
Support: support@getgrm.tech
|
|
374
|
-
`
|
|
454
|
+
const HIVE_RULES_TEXT = RULES_MD // hive-instructions.js (0.14.4); HIVE_RULES.md in the package is generated from it
|
|
375
455
|
|
|
376
456
|
/**
|
|
377
457
|
* Where an account licence may come from, in precedence order — and where it may NOT.
|
|
@@ -461,7 +541,10 @@ export function acquireFolderLock(root, room, log = console.log) {
|
|
|
461
541
|
return release
|
|
462
542
|
}
|
|
463
543
|
|
|
464
|
-
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false, listener = false, app = '' }) {
|
|
544
|
+
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false, listener = false, app = '', gatesDir: gatesDirOpt = '' }) {
|
|
545
|
+
// Where this machine keeps its gate approvals (0.14.4 S-1): outside the synced folder, so no peer can write one.
|
|
546
|
+
const GATES_DIR = gatesDirOpt || gatesDir()
|
|
547
|
+
const gateSkipSaid = new Set() // (agent, command) pairs already announced as unchecked
|
|
465
548
|
// BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
|
|
466
549
|
// then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
|
|
467
550
|
// and the only symptom is other people being told "@X is NOT in the room" about someone who is
|
|
@@ -910,7 +993,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
910
993
|
let real = false
|
|
911
994
|
for (let i = 0; i < arr.length && !real; i++)
|
|
912
995
|
for (let j = i + 1; j < arr.length && !real; j++) {
|
|
913
|
-
const m = merge3(base, arr[i].text, arr[j].text)
|
|
996
|
+
const m = merge3(base, arr[i].text, arr[j].text, { sideBySide: true }) // the sync rule (#17)
|
|
914
997
|
if (m.conflict || hasConflictMarkers(m.text)) real = true
|
|
915
998
|
}
|
|
916
999
|
if (!real) continue
|
|
@@ -960,7 +1043,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
960
1043
|
// Carry each side's SIGNED intent into the announcement: the WHY is what lets any
|
|
961
1044
|
// agent in the room reconcile the fork (via hive_resolve) instead of picking a winner.
|
|
962
1045
|
const why = rec.versions.map((v) => `${v.name}: "${versionIntent(fe, rec, v.hash) || 'no stated intent'}"`).join(' | ')
|
|
963
|
-
try {
|
|
1046
|
+
try { postOnce(`fork:${relPath}:${contentHash(rec.versions.map((v) => v.hash).sort().join(',')).slice(0, 16)}`, `⚠ SILENT FORK in ${relPath}: ${who} changed the same region concurrently. Signed intents — ${why}. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers. ANY agent may reconcile BOTH intents: call hive_resolve with the full resolved file — it is validated (parses, no dangling refs) before it lands.`, { observer: true }) } catch { }
|
|
964
1047
|
}
|
|
965
1048
|
}
|
|
966
1049
|
// A resolution landed elsewhere: the shared text is clean again. Adopt it to disk.
|
|
@@ -1258,7 +1341,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1258
1341
|
// to everybody; this is what lets a join tell another agent with the same name (refused, see
|
|
1259
1342
|
// sameNameElsewhere) from this agent a moment ago, restarting in the same folder (allowed).
|
|
1260
1343
|
const IID = crypto.createHash('sha256').update(`${os.hostname()}|${ROOT}`).digest('hex').slice(0, 12)
|
|
1261
|
-
|
|
1344
|
+
const joinedAt = Date.now()
|
|
1345
|
+
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID, joined: joinedAt }) // identity is implicit in the client
|
|
1262
1346
|
|
|
1263
1347
|
// ---- relay reachability, said out loud exactly once -------------------------------
|
|
1264
1348
|
// y-websocket retries forever and silently. That is the right RECOVERY behaviour and
|
|
@@ -1440,7 +1524,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1440
1524
|
`Do not trust this folder's contents meanwhile.`,
|
|
1441
1525
|
})).text
|
|
1442
1526
|
log(`[${name}] !! ${msg}`)
|
|
1443
|
-
try {
|
|
1527
|
+
try { postOnce(`notsyncing:${code || 'stuck'}`, `[!] @all ${msg}`, { observer: true, ttlMs: 10 * 60 * 1000 }) } catch { /* chat may itself be unreachable — the log still carries it */ }
|
|
1444
1528
|
}, 4000)
|
|
1445
1529
|
if (refusalTimer.unref) refusalTimer.unref()
|
|
1446
1530
|
}
|
|
@@ -1524,6 +1608,270 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1524
1608
|
})
|
|
1525
1609
|
}
|
|
1526
1610
|
|
|
1611
|
+
// ---- SAME-LINE RACE (P0-F; docs/rules-rewrite/p0f-design.md) ----
|
|
1612
|
+
// A save held on the way for less than LATE_OWN_MS is still part of its race; longer is an offline edit (F4).
|
|
1613
|
+
const LATE_OWN_MS = 2 * 60 * 1000, CLASH_SETTLE_MS = Number(process.env.HIVE_CLASH_SETTLE_MS) || 750, CLASH_HOLD_MS = 8000, RECENT_TEXTS = 8
|
|
1614
|
+
// The last few texts this folder held for a file, by hash: a peer's intent names the text it was made
|
|
1615
|
+
// from, and this is how we know it.
|
|
1616
|
+
function rememberText(fe, t) {
|
|
1617
|
+
const h = contentHash(t)
|
|
1618
|
+
if (fe.recent.has(h)) fe.recent.delete(h)
|
|
1619
|
+
fe.recent.set(h, t)
|
|
1620
|
+
while (fe.recent.size > RECENT_TEXTS) fe.recent.delete(fe.recent.keys().next().value)
|
|
1621
|
+
return h
|
|
1622
|
+
}
|
|
1623
|
+
// Called inside the transaction that sends a change from `from` to `to`.
|
|
1624
|
+
function recordIntent(relPath, fe, from, to, mine = null) {
|
|
1625
|
+
// A text holding conflict markers is a merge's verdict, not one author's edit, and the room has been
|
|
1626
|
+
// told about it already. As an intent it made a neighbour's edit look like a clash (bigproject W1), so
|
|
1627
|
+
// it never starts one (m). But it is still recorded (F5): a race resolved after it must know this
|
|
1628
|
+
// change is in the shared text, and what the author's own version was (v: from -> their file).
|
|
1629
|
+
const marked = hasConflictMarkers(to)
|
|
1630
|
+
if (marked && (mine == null || hasConflictMarkers(mine))) return
|
|
1631
|
+
const h = intentHunks(from, to)
|
|
1632
|
+
if (!h) return
|
|
1633
|
+
const vh = marked ? intentHunks(from, mine) : null
|
|
1634
|
+
if (marked && !vh) return
|
|
1635
|
+
const b = rememberText(fe, from)
|
|
1636
|
+
rememberText(fe, to)
|
|
1637
|
+
const me = fe.doc.clientID
|
|
1638
|
+
const v = marked ? { b, h, n: name, m: 1, v: vh } : { b, h, n: name }
|
|
1639
|
+
// Our own last save, for P0-F-12: if it reaches the room after a race on its line was resolved, this
|
|
1640
|
+
// client is the one that repairs it. Kept apart from `intents`, which forgets after the window.
|
|
1641
|
+
if (!marked) fe.lastOwn = { base: from, side: to, at: Date.now() }
|
|
1642
|
+
fe.intent.set(String(me), v)
|
|
1643
|
+
fe.intents.set(me, { ...v, at: Date.now() })
|
|
1644
|
+
// An intent only matters while a race could still be arriving. Left in place it stays in the file's
|
|
1645
|
+
// history forever, and a client gets a new id on every start (review F3), so take ours out after the
|
|
1646
|
+
// window, unless a later save has replaced it. The window is LATE_OWN_MS, not 15 s: a client whose
|
|
1647
|
+
// connection dropped for longer than that resyncs in ONE update, where an intent set and deleted is
|
|
1648
|
+
// not there at all. Nobody saw the race, and the fused line stayed, unmarked (soak, a 25 s hold).
|
|
1649
|
+
const t = setTimeout(() => {
|
|
1650
|
+
if (stopped || !fileDocs.has(relPath) || fe.intent.get(String(me)) !== v) return
|
|
1651
|
+
try { fe.doc.transact(() => fe.intent.delete(String(me))) } catch { }
|
|
1652
|
+
}, LATE_OWN_MS + 1000)
|
|
1653
|
+
if (t.unref) t.unref()
|
|
1654
|
+
}
|
|
1655
|
+
// P0-F-12: A SAME-LINE SAVE THAT ARRIVES AFTER ITS RACE WAS RESOLVED (same-line-very-late-test). It was
|
|
1656
|
+
// made at the same moment as the race's saves, but reached the room only after the room had written
|
|
1657
|
+
// the race's block, and the CRDT put what it inserted beside the block (`wednes<<<<<<< Bob`). Any
|
|
1658
|
+
// client that can explain the text that way (lateOverBlock) knows the repair: the late author's
|
|
1659
|
+
// version added to the block (merge3's late-save merge). The late author's own client writes it -- it
|
|
1660
|
+
// is online, its save just arrived -- and every other client holds its disk until it lands. Returns the
|
|
1661
|
+
// bases it took over, so the race path leaves them alone.
|
|
1662
|
+
function checkLate(relPath, txn) {
|
|
1663
|
+
const fe = fileDocs.get(relPath)
|
|
1664
|
+
const taken = new Set()
|
|
1665
|
+
if (!fe || !fe.intent) return taken
|
|
1666
|
+
const current = fe.text.toString()
|
|
1667
|
+
const now = Date.now()
|
|
1668
|
+
// Only against a race's resolution: its block names its authors. A block a merge raised ("local
|
|
1669
|
+
// (yours)" / "incoming (theirs)") is an author's save, and the race path decides how it joins (F5
|
|
1670
|
+
// builds the block from what each author saved, same-line-later-save-test R10).
|
|
1671
|
+
const repair = (base, side, who) => {
|
|
1672
|
+
const x = lateOverBlock(base, side, current)
|
|
1673
|
+
if (x == null || /^(<<<<<<<|>>>>>>>) .*(local \(yours\)|incoming \(theirs\))/m.test(x)) return null
|
|
1674
|
+
return merge3(base, side, x, { lateName: who }).text
|
|
1675
|
+
}
|
|
1676
|
+
const own = fe.lastOwn
|
|
1677
|
+
if (own && (now - own.at >= LATE_OWN_MS || current === own.side || (blockVersions(current) || { versions: [] }).versions.includes(own.side))) fe.lastOwn = null
|
|
1678
|
+
else if (own) {
|
|
1679
|
+
const final = repair(own.base, own.side, name)
|
|
1680
|
+
if (final != null && final !== current) {
|
|
1681
|
+
taken.add(contentHash(own.base))
|
|
1682
|
+
fe.clashHold = { final, until: now + CLASH_HOLD_MS, late: true }
|
|
1683
|
+
log(`[${name}] ${relPath}: this save of a line reached the room after a race on that line was resolved; adding it to the block`)
|
|
1684
|
+
setTimeout(() => {
|
|
1685
|
+
// The text moved on (another change arrived in the same burst): that change's own check has
|
|
1686
|
+
// already taken this save up again, against the newer text. lastOwn stays until a repair lands.
|
|
1687
|
+
if (stopped || fe.text.toString() !== current) { if (fe.clashHold && fe.clashHold.final === final) releaseHold(relPath, fe); return }
|
|
1688
|
+
fe.lastOwn = null
|
|
1689
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1690
|
+
rememberText(fe, final)
|
|
1691
|
+
try { postOnce(`late:${relPath}:${contentHash(final)}`, `[!] ${relPath}: ${name} saved the same line as the others at the same moment, but the save arrived after their conflict was written. ${name}'s version is now one more version in the conflict block.`) } catch { }
|
|
1692
|
+
releaseHold(relPath, fe)
|
|
1693
|
+
}, 0)
|
|
1694
|
+
return taken
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const keys = txn.changed && txn.changed.get(fe.intent)
|
|
1698
|
+
if (!keys) return taken
|
|
1699
|
+
for (const k of keys) {
|
|
1700
|
+
const v = fe.intent.get(k)
|
|
1701
|
+
if (!v || v.m || !Array.isArray(v.h) || typeof v.b !== 'string' || Number(k) === fe.doc.clientID) continue
|
|
1702
|
+
const base = fe.recent.get(v.b)
|
|
1703
|
+
if (base == null) continue
|
|
1704
|
+
const final = repair(base, applyIntent(base, v.h), v.n || `client ${k}`)
|
|
1705
|
+
if (final == null || final === current) continue
|
|
1706
|
+
taken.add(v.b)
|
|
1707
|
+
fe.clashHold = { final, until: now + CLASH_HOLD_MS, late: true }
|
|
1708
|
+
log(`[${name}] ${relPath}: ${v.n || k}'s save of a line arrived after the race on it was resolved; waiting for it to be added to the block`)
|
|
1709
|
+
const t = setTimeout(() => {
|
|
1710
|
+
if (stopped || fe.clashHold == null || fe.clashHold.final !== final) return
|
|
1711
|
+
if (fe.text.toString() !== final) try { postOnce(`late:${relPath}:${k}:${v.b}`, `[!] ${relPath}: ${v.n || k}'s save of a line arrived after the conflict on it was written, and it was not added to the block in ${CLASH_HOLD_MS / 1000} s. The block may hold a fragment of that save. Check it by hand.`, { observer: true }) } catch { }
|
|
1712
|
+
releaseHold(relPath, fe)
|
|
1713
|
+
}, CLASH_HOLD_MS + 50)
|
|
1714
|
+
if (t.unref) t.unref()
|
|
1715
|
+
}
|
|
1716
|
+
return taken
|
|
1717
|
+
}
|
|
1718
|
+
// A remote transaction arrived. If it carries an intent made from the same text as another recent
|
|
1719
|
+
// intent, and the two changed the same line, the shared text now holds their character union.
|
|
1720
|
+
//
|
|
1721
|
+
// WHY THE WAIT. Saves made "at the same moment" are saves made before any of them reached the others,
|
|
1722
|
+
// so they arrive within about one round trip of each other, in different orders on different peers.
|
|
1723
|
+
// Resolving on the second arrival let two peers each see a different pair, each be the lowest of its
|
|
1724
|
+
// pair, and both write: three saves became two, and one author's line was lost (R6). So every peer
|
|
1725
|
+
// first holds its disk, waits CLASH_SETTLE_MS for the rest of the race to arrive, and then decides from
|
|
1726
|
+
// everything made from that text: the same group, the same text, and one writer, the lowest client.
|
|
1727
|
+
function checkClash(relPath, txn, taken = new Set()) {
|
|
1728
|
+
const fe = fileDocs.get(relPath)
|
|
1729
|
+
if (!fe || !fe.intent) return
|
|
1730
|
+
const keys = txn.changed && txn.changed.get(fe.intent)
|
|
1731
|
+
if (!keys || !keys.size) return
|
|
1732
|
+
const now = Date.now()
|
|
1733
|
+
// Kept as long as a held save still counts as a race (LATE_OWN_MS), not just the race window: a
|
|
1734
|
+
// save 18 s late met a line one other author had changed, and only those two clients still knew of
|
|
1735
|
+
// both saves, so every other disk took the fused line until the block landed (same-line-soak-test).
|
|
1736
|
+
for (const [c, v] of fe.intents) if (now - v.at > LATE_OWN_MS) fe.intents.delete(c)
|
|
1737
|
+
// Our own last save stays in the race until it has been seen (P0-F-12): when it was held on the way,
|
|
1738
|
+
// the others' saves reach us long after the window, and without it here their union went to this disk.
|
|
1739
|
+
const own = fe.lastOwn, me = fe.doc.clientID
|
|
1740
|
+
if (own && now - own.at < LATE_OWN_MS && !fe.intents.has(me)) {
|
|
1741
|
+
const h = intentHunks(own.base, own.side)
|
|
1742
|
+
if (h) fe.intents.set(me, { b: contentHash(own.base), h, n: name, at: own.at })
|
|
1743
|
+
}
|
|
1744
|
+
const bases = new Set()
|
|
1745
|
+
for (const k of keys) {
|
|
1746
|
+
const v = fe.intent.get(k)
|
|
1747
|
+
const c = Number(k)
|
|
1748
|
+
if (!v || !Array.isArray(v.h) || typeof v.b !== 'string' || c === fe.doc.clientID) continue
|
|
1749
|
+
fe.intents.set(c, { b: v.b, h: v.h, n: v.n, at: now, ...(v.m ? { m: 1, v: Array.isArray(v.v) ? v.v : v.h } : {}) })
|
|
1750
|
+
if (!v.m) bases.add(v.b)
|
|
1751
|
+
}
|
|
1752
|
+
for (const b of bases) {
|
|
1753
|
+
if (taken.has(b) || (fe.clashHold && fe.clashHold.late)) continue
|
|
1754
|
+
if (fe.recent.get(b) == null || !clashGroup(fe, b)) continue
|
|
1755
|
+
// Hold this disk now: the shared text may already be the union.
|
|
1756
|
+
fe.clashHold = { final: null, until: now + CLASH_SETTLE_MS + CLASH_HOLD_MS }
|
|
1757
|
+
if (fe.clashTimers.has(b)) continue
|
|
1758
|
+
const t = setTimeout(() => { fe.clashTimers.delete(b); if (!stopped) resolveClash(relPath, fe, b) }, CLASH_SETTLE_MS)
|
|
1759
|
+
if (t.unref) t.unref()
|
|
1760
|
+
fe.clashTimers.set(b, t)
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
// The intents made from text b that clash with another one, as clientID -> hunks; null if none do.
|
|
1764
|
+
function clashGroup(fe, b) {
|
|
1765
|
+
const same = [...fe.intents].filter(([, v]) => v.b === b && !v.m)
|
|
1766
|
+
const group = new Map()
|
|
1767
|
+
for (const [c, v] of same) for (const [d, w] of same) if (c !== d && hunksClash(v.h, w.h)) { group.set(c, v.h); break }
|
|
1768
|
+
return group.size >= 2 ? group : null
|
|
1769
|
+
}
|
|
1770
|
+
function releaseHold(relPath, fe) {
|
|
1771
|
+
fe.clashHold = null
|
|
1772
|
+
if (fe.pendingLocal) runDeferred(relPath, fe)
|
|
1773
|
+
else reconcile(relPath, 'remote')
|
|
1774
|
+
}
|
|
1775
|
+
// The save that waited (deferLocal) goes out now. It was made from what this disk held -- the last
|
|
1776
|
+
// synced text -- and the resolution has landed since. When its lines and the resolution's do not
|
|
1777
|
+
// overlap, both are applied to that text, line by line: exact, no character CRDT and no merge3 (which
|
|
1778
|
+
// strips markers, and dropped Bob's version of the resolved block, R13). When they overlap, the ordinary
|
|
1779
|
+
// merge decides, against that same text.
|
|
1780
|
+
function runDeferred(relPath, fe) {
|
|
1781
|
+
fe.pendingLocal = false
|
|
1782
|
+
let disk = null
|
|
1783
|
+
try { disk = readText(path.join(ROOT, relPath)) } catch { }
|
|
1784
|
+
const base = bases.get(relPath), doc = fe.text.toString()
|
|
1785
|
+
if (disk == null || base == null || disk === base || disk === doc) { reconcile(relPath, 'remote'); return }
|
|
1786
|
+
const mine = intentHunks(base, disk), theirs = intentHunks(base, doc)
|
|
1787
|
+
if (mine && theirs && !linesOverlap(mine, theirs)) {
|
|
1788
|
+
const merged = applyTagged(base.split('\n'), tagged(new Map([[0, theirs], [1, mine]]))).join('\n')
|
|
1789
|
+
log(`[${name}] ${relPath}: the save made while a race was resolved goes out on top of the resolution`)
|
|
1790
|
+
fe.doc.transact(() => { applyDiff(fe.text, merged); if (!FORK_GATE) recordIntent(relPath, fe, doc, merged, merged) })
|
|
1791
|
+
if (merged !== disk) writeToDisk(relPath, merged)
|
|
1792
|
+
known.add(relPath); bases.set(relPath, merged); forkBases.set(relPath, merged)
|
|
1793
|
+
return
|
|
1794
|
+
}
|
|
1795
|
+
fe.deferredMerge = true
|
|
1796
|
+
try { reconcile(relPath, 'local') } finally { fe.deferredMerge = false }
|
|
1797
|
+
}
|
|
1798
|
+
function deferLocal(relPath, fe) {
|
|
1799
|
+
unsentLocal.add(relPath)
|
|
1800
|
+
if (fe.pendingLocal) return
|
|
1801
|
+
fe.pendingLocal = true
|
|
1802
|
+
const t = setTimeout(() => { if (!stopped && fe.pendingLocal) runDeferred(relPath, fe) }, Math.max(0, fe.clashHold.until - Date.now()) + 20)
|
|
1803
|
+
if (t.unref) t.unref()
|
|
1804
|
+
}
|
|
1805
|
+
function resolveClash(relPath, fe, b) {
|
|
1806
|
+
if (fe.clashHold && fe.clashHold.late) return // P0-F-12 has this file: its own timer ends the hold
|
|
1807
|
+
const base = fe.recent.get(b)
|
|
1808
|
+
const group = base == null ? null : clashGroup(fe, b)
|
|
1809
|
+
if (!group) { releaseHold(relPath, fe); return }
|
|
1810
|
+
const ids = [...group.keys()].sort((x, y) => x - y)
|
|
1811
|
+
const key = `${b}|${ids.join(',')}`
|
|
1812
|
+
// Every save made from this text at the same moment is in the shared text's union, the ones that
|
|
1813
|
+
// clash and the ones that do not: the resolution is computed from all of them, or it would delete a
|
|
1814
|
+
// non-clashing author's edit (and be refused, since the union would not match: R9).
|
|
1815
|
+
const all = new Map([...fe.intents].filter(([, v]) => v.b === b && !v.m).map(([c, v]) => [c, v.h]))
|
|
1816
|
+
// F5. A save made from another text after the race began. When that text was what its author held --
|
|
1817
|
+
// one side, or a clean merge of sides -- the save is that author's version of the file from `base`,
|
|
1818
|
+
// and it joins the resolution (its hunks rebased onto `base`). Any other is someone's later edit that
|
|
1819
|
+
// the resolution would overwrite: then do not resolve, and say so.
|
|
1820
|
+
const since = Math.min(...ids.map((i) => (fe.intents.get(i) || {}).at || Infinity))
|
|
1821
|
+
const members = new Map(all), laters = []
|
|
1822
|
+
let unexplained = false
|
|
1823
|
+
// Whether a save came after the race is decided by what it was made from, not by when this peer heard
|
|
1824
|
+
// of it: arrival times are local, and a side's time here can be later than a save built on that side
|
|
1825
|
+
// (R9, 1 run in 40). Only a save nobody can explain is judged by time: older ones predate the race.
|
|
1826
|
+
for (const [c, v] of [...fe.intents].filter(([, v]) => v.b !== b).sort((x, y) => x[1].at - y[1].at)) {
|
|
1827
|
+
const ex = explainBase(base, all, v.b)
|
|
1828
|
+
// The author's own version: for a merge that raised markers, the file they saved, not the markers.
|
|
1829
|
+
const rebased = ex && intentHunks(base, applyIntent(ex.text, v.m ? v.v : v.h))
|
|
1830
|
+
if (!rebased) { if (v.at > since) { unexplained = true; break } continue }
|
|
1831
|
+
members.set(c, rebased)
|
|
1832
|
+
laters.push({ id: c, after: ex.after, h: v.h })
|
|
1833
|
+
}
|
|
1834
|
+
const final = clashFinal(base, members, new Map([...members.keys()].map((i) => [i, (fe.intents.get(i) || {}).n])))
|
|
1835
|
+
const current = fe.text.toString()
|
|
1836
|
+
const who = ids.map((i) => (fe.intents.get(i) || {}).n || String(i)).join(', ')
|
|
1837
|
+
if (fe.clashDone.has(key) || current === final) { releaseHold(relPath, fe); return }
|
|
1838
|
+
fe.clashDone.add(key)
|
|
1839
|
+
if (unexplained || !clashOnlyRegion(base, all, final, current, laters)) {
|
|
1840
|
+
// The file changed outside the clash since: writing the resolution would delete that change.
|
|
1841
|
+
log(`[${name}] same-line race in ${relPath} (${who}) not resolved: the file changed since`)
|
|
1842
|
+
// Markers already in the file: a merge has flagged it and said so. A second line is noise --
|
|
1843
|
+
// unless the file also holds a line nobody wrote: the markers are then another race's, on other
|
|
1844
|
+
// lines, and this one's fused line would stay with nothing said (same-line-soak-test, seed 2).
|
|
1845
|
+
const wrote = new Set(base.split('\n'))
|
|
1846
|
+
for (const [, v] of fe.intents) { const t = fe.recent.get(v.b); if (t != null) for (const l of applyIntent(t, v.m ? v.v : v.h).split('\n')) wrote.add(l) }
|
|
1847
|
+
const fused = current.split('\n').some((l) => !wrote.has(l) && !/^(<<<<<<<|>>>>>>>)( |$)|^=======$/.test(l))
|
|
1848
|
+
if (fused || /[^\n](<<<<<<< |>>>>>>> )/.test(current)) try { postOnce(`race:${relPath}:${key}`, `[!] ${relPath}: ${who} saved the same line at the same moment, and the file changed again before it could be resolved, so the line may hold a mix of their text. Check it by hand.`, { observer: ids[0] !== fe.doc.clientID }) } catch { }
|
|
1849
|
+
releaseHold(relPath, fe); return
|
|
1850
|
+
}
|
|
1851
|
+
const writer = ids[0] === fe.doc.clientID
|
|
1852
|
+
log(`[${name}] same-line race in ${relPath}: ${who} saved one line at the same moment; ${writer ? 'writing' : 'waiting for'} the resolution`)
|
|
1853
|
+
if (writer) {
|
|
1854
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1855
|
+
rememberText(fe, final)
|
|
1856
|
+
releaseHold(relPath, fe)
|
|
1857
|
+
return
|
|
1858
|
+
}
|
|
1859
|
+
// Everyone else holds the disk until the resolution lands. If the writer never delivers it, a client
|
|
1860
|
+
// in the race writes it itself, and a bystander lets the shared text through.
|
|
1861
|
+
const participant = group.has(fe.doc.clientID)
|
|
1862
|
+
fe.clashHold = { final, until: Date.now() + CLASH_HOLD_MS }
|
|
1863
|
+
const t = setTimeout(() => {
|
|
1864
|
+
if (stopped || fe.clashHold == null || fe.clashHold.final !== final) return
|
|
1865
|
+
const cur = fe.text.toString()
|
|
1866
|
+
if (cur !== final && participant && clashOnlyRegion(base, all, final, cur, laters)) {
|
|
1867
|
+
log(`[${name}] same-line race in ${relPath}: no resolution arrived in ${CLASH_HOLD_MS / 1000} s, writing it`)
|
|
1868
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1869
|
+
}
|
|
1870
|
+
releaseHold(relPath, fe)
|
|
1871
|
+
}, CLASH_HOLD_MS + 50)
|
|
1872
|
+
if (t.unref) t.unref()
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1527
1875
|
function openFile(relPath) {
|
|
1528
1876
|
let e = fileDocs.get(relPath)
|
|
1529
1877
|
if (e) return e
|
|
@@ -1558,20 +1906,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1558
1906
|
// Deterministic (computed from the ledger), converged across peers, and rendered to
|
|
1559
1907
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1560
1908
|
const conflict = fdoc.getMap('conflict')
|
|
1909
|
+
// intent: clientID -> { b, h, n }, what this client's last save changed and from which text (P0-F).
|
|
1910
|
+
const intent = fdoc.getMap('intent')
|
|
1561
1911
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1562
1912
|
// diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
|
|
1563
1913
|
// the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
|
|
1564
1914
|
// is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
|
|
1565
1915
|
let diskAtOpen
|
|
1566
1916
|
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 }
|
|
1917
|
+
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen, intent, recent: new Map(), intents: new Map(), clashDone: new Set(), clashHold: null, clashTimers: new Map() }
|
|
1568
1918
|
fileDocs.set(relPath, e)
|
|
1569
1919
|
// A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
|
|
1570
1920
|
fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
|
|
1571
1921
|
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1572
1922
|
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1573
1923
|
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
1574
|
-
text
|
|
1924
|
+
// Every text this client itself puts in the room is remembered, however it got there: a file's
|
|
1925
|
+
// first publish records no intent, so its author did not know the text two later saves were made
|
|
1926
|
+
// from, saw no race, and wrote their fused line to its disk (same-line-soak-test, seed 2).
|
|
1927
|
+
text.observe((_ev, txn) => { if (txn.local) rememberText(e, text.toString()) })
|
|
1928
|
+
text.observe((_ev, txn) => { if (!txn.local) { if (!FORK_GATE) checkClash(relPath, txn, checkLate(relPath, txn)); reconcile(relPath, 'remote') } }) // remote content edits
|
|
1575
1929
|
bin.observe((_ev, txn) => { if (!txn.local) reconcileBinary(relPath, 'remote') }) // remote binary edits
|
|
1576
1930
|
// A divergent receipt can arrive slightly after (or before) the fused content — so
|
|
1577
1931
|
// re-run the fork check whenever the provenance ledger changes too, not only on edits.
|
|
@@ -2356,7 +2710,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2356
2710
|
if (keptAs) {
|
|
2357
2711
|
log(`[${name}] local unshared file ${relPath} overwritten by room write; copy preserved in ${keptAs}`)
|
|
2358
2712
|
try {
|
|
2359
|
-
|
|
2713
|
+
note(`· ${relPath} on ${name}'s disk was ${unshared.reason} and was overwritten by a room write — local copy preserved in ${keptAs}.`)
|
|
2360
2714
|
} catch { }
|
|
2361
2715
|
}
|
|
2362
2716
|
}
|
|
@@ -2514,10 +2868,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2514
2868
|
if (kept.length) {
|
|
2515
2869
|
const one = kept.length === 1
|
|
2516
2870
|
parts.push(`${one ? '1 holds' : `${kept.length} hold`} content the room has never seen, so neither copy was changed and the room's copy was NOT adopted: ${names(kept)}. ` +
|
|
2517
|
-
`In ${name}'s folder ${one ? 'it still holds' : 'they still hold'} ${name}'s version. To make one the room's version, claim it and hive_edit it with the full file. ` +
|
|
2871
|
+
`In ${name}'s folder ${one ? 'it still holds' : 'they still hold'} ${name}'s version, and nothing syncs for ${one ? 'it' : 'them'} until ${name} saves ${one ? 'it' : 'them'} again. To make one the room's version, claim it and hive_edit it with the full file. ` +
|
|
2518
2872
|
`Don't delete a local copy to get the room's: a deletion syncs, and removes the file for everyone.`)
|
|
2519
2873
|
}
|
|
2520
|
-
|
|
2874
|
+
// What concerns THIS folder only (line endings, older copies it now has) is a note for hive_status; only
|
|
2875
|
+
// content the room has never seen is the room's business, because nobody else has it (0.14.4, C5).
|
|
2876
|
+
note(parts.join(' '))
|
|
2877
|
+
if (kept.length) { try { say([parts[0], parts[parts.length - 1]].join(' ')) } catch { } } // the kept part is pushed last
|
|
2521
2878
|
}
|
|
2522
2879
|
|
|
2523
2880
|
function reconcileBinary(relPath, origin = 'local') {
|
|
@@ -2621,7 +2978,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2621
2978
|
if (keptAs) {
|
|
2622
2979
|
log(`[${name}] local binary ${relPath} overwritten by remote write (winner: ${remoteBy || 'teammate'}); copy preserved in ${keptAs}`)
|
|
2623
2980
|
try {
|
|
2624
|
-
|
|
2981
|
+
note(`· ${relPath} updated from room (winner: ${remoteBy || 'teammate'}). Your local copy saved to ${keptAs}.`)
|
|
2625
2982
|
} catch { }
|
|
2626
2983
|
}
|
|
2627
2984
|
}
|
|
@@ -2691,6 +3048,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2691
3048
|
// and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
|
|
2692
3049
|
if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
|
|
2693
3050
|
if (!fe.synced) { if (origin === 'local' && disk !== null) unsentLocal.add(relPath); return }
|
|
3051
|
+
// A detected same-line race is being resolved (P0-F): write nothing from the shared text until the
|
|
3052
|
+
// resolution arrives, so the fused text never reaches this disk. A local save still goes through.
|
|
3053
|
+
if (fe.clashHold && fe.text.toString() !== fe.clashHold.final && Date.now() < fe.clashHold.until) {
|
|
3054
|
+
// A local save made while a race is being resolved waits for the resolution too (F5): merged now,
|
|
3055
|
+
// against the shared text, it would take the fused line into this disk and publish it again. The
|
|
3056
|
+
// edit is safe on disk meanwhile; it goes out as soon as the hold ends.
|
|
3057
|
+
if (origin === 'local') deferLocal(relPath, fe)
|
|
3058
|
+
return
|
|
3059
|
+
}
|
|
3060
|
+
if (fe.clashHold) { fe.clashHold = null; if (fe.pendingLocal) { runDeferred(relPath, fe); return } }
|
|
2694
3061
|
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2695
3062
|
// room in either direction would decide something nobody decided: the room's next change would
|
|
2696
3063
|
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
@@ -2768,6 +3135,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2768
3135
|
}
|
|
2769
3136
|
dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
|
|
2770
3137
|
const docText = fe.text.toString()
|
|
3138
|
+
rememberText(fe, docText)
|
|
2771
3139
|
// An empty shared doc is authoritative ONLY if we have history for this path.
|
|
2772
3140
|
// A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
|
|
2773
3141
|
// different connections — there is no atomicity across them. A client that joins a large
|
|
@@ -2949,12 +3317,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2949
3317
|
return
|
|
2950
3318
|
}
|
|
2951
3319
|
}
|
|
2952
|
-
if (r0.text !== docText) { fe.doc.transact(() => applyDiff(yt, r0.text)); signPublished(relPath, docText, r0.text, disk) }
|
|
3320
|
+
if (r0.text !== docText) { fe.doc.transact(() => { applyDiff(yt, r0.text); if (!FORK_GATE) recordIntent(relPath, fe, docText, r0.text) }); signPublished(relPath, docText, r0.text, disk) }
|
|
2953
3321
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
2954
3322
|
if (r0.conflict) conflicted.add(relPath)
|
|
2955
3323
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
2956
3324
|
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
2957
|
-
try {
|
|
3325
|
+
try { postOnce(`race:${relPath}`, `⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`, { ttlMs: 10 * 60 * 1000 }) } catch { }
|
|
2958
3326
|
return
|
|
2959
3327
|
}
|
|
2960
3328
|
// REJOIN, NOT A FIRST JOIN. If a previous session of this client wrote this file in
|
|
@@ -3018,7 +3386,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3018
3386
|
// the local side first so the pre-merge bytes are recoverable regardless of outcome.
|
|
3019
3387
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
3020
3388
|
const rj = icrMerge3(anc, disk, docText, relPath)
|
|
3021
|
-
if (rj.text !== docText) { fe.doc.transact(() => applyDiff(yt, rj.text)); signPublished(relPath, docText, rj.text, disk) }
|
|
3389
|
+
if (rj.text !== docText) { fe.doc.transact(() => { applyDiff(yt, rj.text); if (!FORK_GATE) recordIntent(relPath, fe, docText, rj.text) }); signPublished(relPath, docText, rj.text, disk) }
|
|
3022
3390
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
3023
3391
|
if (rj.conflict) conflicted.add(relPath)
|
|
3024
3392
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
@@ -3066,11 +3434,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3066
3434
|
// and said", never "lost".
|
|
3067
3435
|
let keptAt = 0; try { keptAt = fs.statSync(full).mtimeMs } catch { }
|
|
3068
3436
|
keptUnsent.set(relPath, keptAt)
|
|
3069
|
-
// Said in the room
|
|
3070
|
-
|
|
3071
|
-
|
|
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 { }
|
|
3437
|
+
// Said in the room (review R5: the agent has to act on it), but ONCE PER JOIN, in the first-contact
|
|
3438
|
+
// summary below -- not one line per file in engine words. A teammate rejoining with 40 such files
|
|
3439
|
+
// filled the chat with 40 lines (9 Nov 2026; first-contact-summary-test, red since 0.14.3).
|
|
3074
3440
|
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`)
|
|
3075
3441
|
noteFirstContact('kept', relPath)
|
|
3076
3442
|
return
|
|
@@ -3136,11 +3502,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3136
3502
|
}
|
|
3137
3503
|
}
|
|
3138
3504
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
3139
|
-
|
|
3505
|
+
// A save that waited out a race (F5) was made from what this disk held then -- the last synced text --
|
|
3506
|
+
// not from the resolution that has landed since: merge it from there, and never read the markers it
|
|
3507
|
+
// never saw as resolved by it.
|
|
3508
|
+
const deferred = origin === 'local' && fe.deferredMerge
|
|
3509
|
+
const fork = deferred ? base : forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
3140
3510
|
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
|
|
3141
3511
|
|
|
3142
3512
|
let res, reAdded = false
|
|
3143
|
-
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
3513
|
+
if (origin === 'local' && !deferred && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
3144
3514
|
// Resolving a conflict: the doc still has <<<<<<< markers and this local
|
|
3145
3515
|
// write removed them — the author has resolved it. Their clean version wins.
|
|
3146
3516
|
res = { text: disk, conflict: false }
|
|
@@ -3230,7 +3600,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3230
3600
|
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
3231
3601
|
lastLocalEdit = relPath
|
|
3232
3602
|
}
|
|
3233
|
-
|
|
3603
|
+
// The save's INTENT travels in the same transaction as its characters (P0-F): what it changed, and
|
|
3604
|
+
// from which text, so a peer that receives two saves of one line made from the same text can tell.
|
|
3605
|
+
fileDocs.get(relPath).doc.transact(() => { applyDiff(yt, res.text); if (!FORK_GATE && res.text !== docText) recordIntent(relPath, fe, docText, res.text, disk) })
|
|
3234
3606
|
// Sign this landed local change into the file's provenance ledger (I1). Stamp the
|
|
3235
3607
|
// author's TRUE intent — the state THEY edited from (fork) -> the content THEY wrote
|
|
3236
3608
|
// (disk) — NOT the live shared text, which under truly-concurrent edits is already
|
|
@@ -3254,10 +3626,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3254
3626
|
if (hasMarkers && !conflicted.has(relPath)) {
|
|
3255
3627
|
conflicted.add(relPath)
|
|
3256
3628
|
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
3257
|
-
try {
|
|
3629
|
+
try { postOnce(`conflict:${relPath}`, `⚠ 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.`, { observer: !res.conflict, clear: `resolved:${relPath}` }) } catch { }
|
|
3258
3630
|
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
3259
3631
|
conflicted.delete(relPath)
|
|
3260
|
-
try {
|
|
3632
|
+
try { postOnce(`resolved:${relPath}`, `✓ conflict in ${relPath} resolved.`, { observer: origin !== 'local', clear: `conflict:${relPath}` }) } catch { }
|
|
3261
3633
|
} else if (reAdded) {
|
|
3262
3634
|
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
3263
3635
|
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
@@ -3275,7 +3647,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3275
3647
|
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
3276
3648
|
icrWarned.set(relPath, res.icrWarning)
|
|
3277
3649
|
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
3278
|
-
try {
|
|
3650
|
+
try { postOnce(`icr:${relPath}:${contentHash(String(res.icrWarning)).slice(0, 16)}`, `⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`, { observer: origin !== 'local' }) } catch { }
|
|
3279
3651
|
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
3280
3652
|
icrWarned.delete(relPath)
|
|
3281
3653
|
}
|
|
@@ -3511,6 +3883,66 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3511
3883
|
if (!text) return
|
|
3512
3884
|
chat.push([{ by: name, kind, at: fmtTime(), text: String(text) }])
|
|
3513
3885
|
}
|
|
3886
|
+
// ONE LINE PER EVENT, NOT ONE PER OBSERVER (0.14.4, C5). Most system lines are said by whoever
|
|
3887
|
+
// caused them (a task, a review, a revert): one poster by nature. But an OBSERVED event -- a conflict
|
|
3888
|
+
// in a shared file, a client on another build, a divergence -- is detected by every peer's own code,
|
|
3889
|
+
// and each posted it: one conflict gave four MERGE CONFLICT lines and then four "resolved" lines
|
|
3890
|
+
// (incident I-16, #722-#725); one odd client gave a MIXED BUILDS line from every other (I-8, #822-#823).
|
|
3891
|
+
// postOnce(id, text): `id` names the event the same way on every peer. The peer that caused it posts
|
|
3892
|
+
// at once. An observer waits 1.5 s plus 1 s per rank among present engines (sorted by name) and posts
|
|
3893
|
+
// only if nobody has; the key lives in the room (`sys_events`) and expires. A peer that cannot write
|
|
3894
|
+
// the key still posts: a missed alarm hides a conflict, a duplicate is only noise.
|
|
3895
|
+
const sysEvents = doc.getMap('sys_events')
|
|
3896
|
+
const SYS_TTL_MS = 6 * 3600 * 1000
|
|
3897
|
+
const sysTimers = new Set()
|
|
3898
|
+
// What only THIS folder needs to know (a local copy kept, a file that differed at join) goes here, to
|
|
3899
|
+
// the log and hive_status, and not to the whole room (C5).
|
|
3900
|
+
const notices = []
|
|
3901
|
+
function note(text) {
|
|
3902
|
+
const t = String(text || '')
|
|
3903
|
+
if (!t) return
|
|
3904
|
+
log(`[${name}] ${t}`)
|
|
3905
|
+
notices.push({ at: Date.now(), text: t })
|
|
3906
|
+
if (notices.length > 50) notices.shift()
|
|
3907
|
+
}
|
|
3908
|
+
function sysRank() {
|
|
3909
|
+
const names = new Set([name])
|
|
3910
|
+
try {
|
|
3911
|
+
for (const st of provider.awareness.getStates().values()) {
|
|
3912
|
+
const u = st && st.user
|
|
3913
|
+
if (u && u.name && !u.listener && !u.passive && u.v === PROTOCOL_VERSION) names.add(u.name)
|
|
3914
|
+
}
|
|
3915
|
+
} catch { }
|
|
3916
|
+
return [...names].sort().indexOf(name)
|
|
3917
|
+
}
|
|
3918
|
+
function pruneSysEvents(now) {
|
|
3919
|
+
let n = 0
|
|
3920
|
+
for (const [k, v] of sysEvents.entries()) {
|
|
3921
|
+
if (++n > 500) break
|
|
3922
|
+
if (!v || now - (Number(v.at) || 0) > (Number(v.ttl) || SYS_TTL_MS)) sysEvents.delete(k)
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
function postOnce(id, text, { observer = false, ttlMs = SYS_TTL_MS, clear = null } = {}) {
|
|
3926
|
+
if (!text) return null
|
|
3927
|
+
if (observer && (passive || listener)) return null // a client nobody waits on never speaks for the room
|
|
3928
|
+
const fire = () => {
|
|
3929
|
+
try {
|
|
3930
|
+
const prev = sysEvents.get(id)
|
|
3931
|
+
if (prev && Date.now() - (Number(prev.at) || 0) < (Number(prev.ttl) || SYS_TTL_MS)) return false
|
|
3932
|
+
doc.transact(() => {
|
|
3933
|
+
sysEvents.set(id, { by: name, at: Date.now(), ttl: ttlMs })
|
|
3934
|
+
if (clear) for (const c of [].concat(clear)) sysEvents.delete(c)
|
|
3935
|
+
pruneSysEvents(Date.now())
|
|
3936
|
+
})
|
|
3937
|
+
} catch { /* fail open: the line is said even if the key could not be written */ }
|
|
3938
|
+
say(text)
|
|
3939
|
+
return true
|
|
3940
|
+
}
|
|
3941
|
+
if (!observer) return fire()
|
|
3942
|
+
const t = setTimeout(() => { sysTimers.delete(t); if (!stopped) { try { fire() } catch { } } }, 1500 + 1000 * Math.max(0, sysRank()))
|
|
3943
|
+
sysTimers.add(t)
|
|
3944
|
+
return null
|
|
3945
|
+
}
|
|
3514
3946
|
function renderChat() {
|
|
3515
3947
|
const out = [
|
|
3516
3948
|
'# Hive Chat — the live coordination channel for everyone in this room.',
|
|
@@ -3830,7 +4262,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3830
4262
|
let myEditing = null, myLastEdit = null
|
|
3831
4263
|
let editingClearTimer = null
|
|
3832
4264
|
const warnedAt = new Map() // file -> last time we warned about co-editing it
|
|
3833
|
-
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID })
|
|
4265
|
+
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID, joined: joinedAt })
|
|
3834
4266
|
function markEditing(relPath) {
|
|
3835
4267
|
myEditing = { file: relPath, at: Date.now() }
|
|
3836
4268
|
myLastEdit = myEditing
|
|
@@ -3858,7 +4290,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3858
4290
|
const now = Date.now()
|
|
3859
4291
|
if (others.length && now - (warnedAt.get(relPath) || 0) > 30000) {
|
|
3860
4292
|
warnedAt.set(relPath, now)
|
|
3861
|
-
try {
|
|
4293
|
+
try { postOnce(`coedit:${relPath}:${[name, ...others].sort().join(',')}`, `⚠ heads-up: ${name} is also editing ${relPath} (with ${others.join(', ')}). Small patches auto-merge; coordinate before a full rewrite.`, { ttlMs: 10 * 60 * 1000 }) } catch { } // the one who just started editing is the cause: it says it at once
|
|
3862
4294
|
}
|
|
3863
4295
|
}
|
|
3864
4296
|
function memberList() {
|
|
@@ -3927,7 +4359,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3927
4359
|
versionWarned.add(u.name)
|
|
3928
4360
|
const theirs = u.v ? `v${u.v}` : 'an older build'
|
|
3929
4361
|
log(`[${name}] ⚠ ${u.name} is on ${theirs} (incompatible with v${PROTOCOL_VERSION}) — they won't sync correctly until everyone updates.`)
|
|
3930
|
-
try {
|
|
4362
|
+
try { postOnce(`proto:${u.name}:${u.v || '-'}`, `⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`, { observer: true }) } catch { }
|
|
3931
4363
|
}
|
|
3932
4364
|
checkBuilds()
|
|
3933
4365
|
}
|
|
@@ -3941,40 +4373,69 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3941
4373
|
// - humans are not flagged: the extension and the browser panel carry no engine build
|
|
3942
4374
|
// - one side of each pair speaks (the name that sorts first), once per build it sees
|
|
3943
4375
|
const buildWarned = new Set()
|
|
4376
|
+
// ONE ALARM PER ROOM, AND ONLY FOR ANOTHER RELEASE (0.14.4, C5). The rule used to be "one side of
|
|
4377
|
+
// each pair speaks", which is still one line per PAIR: two clients on 0.14.2 each posted about the one
|
|
4378
|
+
// on 0.14.3 (#822 + #823), and a client on the same release from another commit raised the same alarm
|
|
4379
|
+
// (I-8). Now the event is the set of releases in the room: it is said once, by whoever sees it first,
|
|
4380
|
+
// naming who runs what. The same release from another commit is a note for this folder (hive_status).
|
|
3944
4381
|
function checkBuilds() {
|
|
3945
|
-
//
|
|
3946
|
-
//
|
|
3947
|
-
|
|
3948
|
-
const
|
|
3949
|
-
|
|
3950
|
-
for (const
|
|
3951
|
-
const u =
|
|
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
|
-
}
|
|
3956
|
-
const speakForOld = reporters.sort()[0] === name
|
|
3957
|
-
|
|
3958
|
-
for (const s of provider.awareness.getStates().values()) {
|
|
3959
|
-
const u = s && s.user
|
|
4382
|
+
// Per CONNECTION, not per name: one participant with two engines open on different builds is its own
|
|
4383
|
+
// alarm (0.14.3 Q-2: "an older connection is still open"), and a map keyed by name would hide it.
|
|
4384
|
+
const labelsOf = new Map() // name -> Set of what its engines run
|
|
4385
|
+
const add = (n, label) => { if (!labelsOf.has(n)) labelsOf.set(n, new Set()); labelsOf.get(n).add(label) }
|
|
4386
|
+
add(name, VERSION_LABEL)
|
|
4387
|
+
for (const st of provider.awareness.getStates().values()) {
|
|
4388
|
+
const u = st && st.user
|
|
3960
4389
|
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION || u.listener) continue
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
const
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
4390
|
+
if (u.hv) add(u.name, String(u.hv))
|
|
4391
|
+
// An AI on this protocol reporting no build is an engine older than build reporting: the rollout
|
|
4392
|
+
// case, and it cannot warn itself. Humans carry no engine build (the extension, the browser panel).
|
|
4393
|
+
else if (String(u.kind || '').toLowerCase() !== 'human') add(u.name, `a build older than build reporting (before ${VERSION})`)
|
|
4394
|
+
}
|
|
4395
|
+
const older = (label) => /^a build older/.test(label)
|
|
4396
|
+
const releaseOf = (label) => (older(label) ? label : String(label).split('+')[0])
|
|
4397
|
+
const on = (label) => (older(label) ? `on ${label}` : `on hivecode ${label}`)
|
|
4398
|
+
// The same release from another commit: a note for this folder, not a room alarm.
|
|
4399
|
+
for (const [n, labels] of labelsOf) {
|
|
4400
|
+
if (n === name) continue
|
|
4401
|
+
for (const label of labels) {
|
|
4402
|
+
if (label === VERSION_LABEL || releaseOf(label) !== VERSION) continue
|
|
4403
|
+
const key = `note|${n}|${label}`
|
|
4404
|
+
if (buildWarned.has(key)) continue
|
|
4405
|
+
buildWarned.add(key)
|
|
4406
|
+
note(`${n} runs hivecode ${label}: the same release as this client (${VERSION_LABEL}), from another commit.`)
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
// One name, two engines on different builds: they write over each other's files.
|
|
4410
|
+
for (const [n, labels] of labelsOf) {
|
|
4411
|
+
if (n === name || labels.size < 2) continue
|
|
4412
|
+
const all = [...labels].sort()
|
|
4413
|
+
const id = `dual:${n}:${all.join('|')}`
|
|
4414
|
+
if (buildWarned.has(id)) continue
|
|
4415
|
+
buildWarned.add(id)
|
|
4416
|
+
log(`[${name}] ⚠ MIXED BUILDS: ${n} has engines ${all.map(on).join(' and ')}`)
|
|
4417
|
+
try { postOnce(id, `⚠ MIXED BUILDS: ${n} has two engines open, ${all.map(on).join(' and ')} (an older connection from ${n} is still open). Two engines under one name can lose file content with no error on either side -- close the older one.`, { observer: true }) } catch { }
|
|
4418
|
+
}
|
|
4419
|
+
// An OLDER RELEASE in the room: one alarm per participant and build, for the whole room. The event
|
|
4420
|
+
// is "X runs B, older than the newest release here": every observer sees the same newest release, so
|
|
4421
|
+
// they name the same event, and presence churn (an agent's state re-published) never re-raises it.
|
|
4422
|
+
const parts = (r) => (older(r) ? [-1] : String(r).split('.').map((x) => parseInt(x, 10) || 0))
|
|
4423
|
+
const cmp = (a, b) => { const x = parts(a), y = parts(b); for (let k = 0; k < Math.max(x.length, y.length); k++) { const d = (x[k] || 0) - (y[k] || 0); if (d) return d } return 0 }
|
|
4424
|
+
const releases = new Set()
|
|
4425
|
+
for (const labels of labelsOf.values()) for (const label of labels) releases.add(releaseOf(label))
|
|
4426
|
+
if (releases.size < 2) return
|
|
4427
|
+
const newest = [...releases].sort(cmp).pop()
|
|
4428
|
+
const current = []
|
|
4429
|
+
for (const [n, labels] of labelsOf) for (const label of labels) if (releaseOf(label) === newest) current.push(`${n} ${on(label)}`)
|
|
4430
|
+
for (const [n, labels] of labelsOf) {
|
|
4431
|
+
for (const label of labels) {
|
|
4432
|
+
if (releaseOf(label) === newest) continue
|
|
4433
|
+
const id = `builds:${n}:${label}`
|
|
4434
|
+
if (buildWarned.has(id)) continue
|
|
4435
|
+
buildWarned.add(id)
|
|
4436
|
+
log(`[${name}] ⚠ MIXED BUILDS: ${n} runs ${older(label) ? label : `hivecode ${label}`}`)
|
|
4437
|
+
try { postOnce(id, `⚠ MIXED BUILDS: ${n} runs ${older(label) ? label : `hivecode ${label}`}, older than the room's newest release (${current.sort().join(', ')}). Two releases in one room can lose file content with no error on either side -- ${n} should update (npx -y hivecode-mcp@latest setup, then restart).`, { observer: true }) } catch { }
|
|
4438
|
+
}
|
|
3978
4439
|
}
|
|
3979
4440
|
}
|
|
3980
4441
|
if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
|
|
@@ -4861,6 +5322,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4861
5322
|
try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
|
|
4862
5323
|
try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
|
|
4863
5324
|
stopped = true
|
|
5325
|
+
for (const t of sysTimers) clearTimeout(t)
|
|
5326
|
+
sysTimers.clear()
|
|
4864
5327
|
try { flushRoomDoc() } catch { }
|
|
4865
5328
|
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
4866
5329
|
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
@@ -4945,6 +5408,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4945
5408
|
}
|
|
4946
5409
|
}
|
|
4947
5410
|
swarmState.set(key, val)
|
|
5411
|
+
// The session that sets a gate has chosen to run that command, so its own machine approves it
|
|
5412
|
+
// (0.14.4 S-1). Nobody else's does: a gate set here runs elsewhere only after that person approves.
|
|
5413
|
+
if (key === GATE_CMD && String(val == null ? '' : val).trim()) {
|
|
5414
|
+
try { writeApproval(room, String(val), `set by ${name} on this machine`, GATES_DIR) } catch { /* no approval, so it simply does not run here */ }
|
|
5415
|
+
}
|
|
4948
5416
|
say(`STATE TRANSITION: [${key}] is now [${val}]`)
|
|
4949
5417
|
},
|
|
4950
5418
|
getState: () => {
|
|
@@ -5074,7 +5542,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5074
5542
|
if (v.stuckLagging) return `${v.peer}:${v.bytes}b/${v.lines}L[STUCK:${v.stuckReason || 'not catching up'}]`
|
|
5075
5543
|
return `${v.peer}:${v.bytes}b/${v.lines}L`
|
|
5076
5544
|
}).join(' ')
|
|
5077
|
-
|
|
5545
|
+
postOnce(`diverge:${p}:${contentHash(detail).slice(0, 16)}`, `[!] DIVERGENCE on ${p} — peers hold DIFFERENT content: ${detail}. Nobody may write this path until it is reconciled; mark it path:${p}=POISONED if it cannot be.`)
|
|
5078
5546
|
}
|
|
5079
5547
|
|
|
5080
5548
|
return {
|
|
@@ -5107,6 +5575,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5107
5575
|
}
|
|
5108
5576
|
},
|
|
5109
5577
|
say, // post a coordination message
|
|
5578
|
+
postOnce, // a system line said once per event in the room, whoever sees it (0.14.4, C5)
|
|
5579
|
+
notices: () => notices.slice(), // what only this folder needs to know (hive_status shows it)
|
|
5110
5580
|
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
5111
5581
|
decide, // approve/deny a task (owner only, if set)
|
|
5112
5582
|
complete, // mark a task done
|
|
@@ -5168,6 +5638,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5168
5638
|
return { ok: true, gated: true, forced: true }
|
|
5169
5639
|
}
|
|
5170
5640
|
|
|
5641
|
+
// A COMMAND FROM THE ROOM IS DATA (0.14.4 S-1). It runs here only if this machine's person
|
|
5642
|
+
// approved this exact command; otherwise the hand-back goes through UNCHECKED -- never refused,
|
|
5643
|
+
// or setting a gate would be a way to stop everyone -- and that is recorded and said once.
|
|
5644
|
+
const approvedBy = gateApproved({ room, cmd: g.cmd, dir: GATES_DIR })
|
|
5645
|
+
if (!approvedBy) {
|
|
5646
|
+
gateResults.set(`${r}|${name}`, { ...verdictRecord({ region: r, by: name, cmd: g.cmd, result: null }), skipped: 'not-approved' })
|
|
5647
|
+
letGo()
|
|
5648
|
+
const said = `${name}|${g.cmd}`
|
|
5649
|
+
if (!gateSkipSaid.has(said)) { gateSkipSaid.add(said); try { say(notApprovedLine(name)) } catch { /* chat is not the point */ } }
|
|
5650
|
+
return { ok: true, gated: false, gateSkipped: 'not-approved', code: GATE_NOT_APPROVED, cmd: g.cmd, message: notApprovedMessage({ region: r, cmd: g.cmd, dir: ROOT }) }
|
|
5651
|
+
}
|
|
5171
5652
|
const result = runGate({ cmd: g.cmd, cwd: ROOT, timeoutMs: g.timeoutMs })
|
|
5172
5653
|
gateResults.set(`${r}|${name}`, verdictRecord({ region: r, by: name, cmd: g.cmd, result }))
|
|
5173
5654
|
if (!result.ok) {
|
|
@@ -5281,7 +5762,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5281
5762
|
if (id === me || !u || !u.name || String(u.name).toLowerCase() !== lc) continue
|
|
5282
5763
|
if (u.listener || u.iid === IID) continue
|
|
5283
5764
|
if (kind === 'human' && u.kind === 'human') continue
|
|
5284
|
-
out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app })
|
|
5765
|
+
out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app, joined: u.joined, clientID: id })
|
|
5285
5766
|
}
|
|
5286
5767
|
return out
|
|
5287
5768
|
},
|