hivecode-mcp 0.14.2 → 0.14.4
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/docs/TROUBLESHOOTING.md +4 -0
- package/hive-await.js +57 -21
- package/hive-branch.js +68 -2
- package/hive-gate.js +116 -2
- package/hive-instructions.js +110 -61
- package/hive-land.js +15 -1
- package/hive-mailbox.js +74 -7
- package/hive-mcp.js +258 -47
- package/hive-mux.js +4 -1
- package/hive-notify.js +21 -5
- package/hive-pr.js +42 -5
- package/hive-receipts.js +56 -5
- package/hive-room-mode.js +143 -37
- package/hive-room-watcher.js +95 -16
- package/hive-seen.js +135 -0
- package/hive-slack.js +62 -4
- package/hive-stop-hook.js +33 -18
- package/hive-transport.js +85 -11
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +13 -4
- package/sync.js +419 -225
- package/docs/HIVE_RULES.md +0 -41
package/sync.js
CHANGED
|
@@ -27,7 +27,8 @@ import ignore from 'ignore'
|
|
|
27
27
|
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } 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
|
|
@@ -229,149 +230,12 @@ const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-
|
|
|
229
230
|
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
230
231
|
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
231
232
|
// .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
|
-
`
|
|
233
|
+
const AGENT_MANUAL_TEXT = AGENT_MANUAL // hive-instructions.js: one source for every agent-facing text (0.14.4)
|
|
272
234
|
|
|
273
235
|
// The hive's law. Auto-written into every room folder so it is ALWAYS present —
|
|
274
236
|
// no setup, no relying on an agent to remember it. The sync layer enforces the
|
|
275
237
|
// 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
|
-
`
|
|
238
|
+
const HIVE_RULES_TEXT = RULES_MD // hive-instructions.js (0.14.4); HIVE_RULES.md in the package is generated from it
|
|
375
239
|
|
|
376
240
|
/**
|
|
377
241
|
* Where an account licence may come from, in precedence order — and where it may NOT.
|
|
@@ -461,7 +325,10 @@ export function acquireFolderLock(root, room, log = console.log) {
|
|
|
461
325
|
return release
|
|
462
326
|
}
|
|
463
327
|
|
|
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 = '' }) {
|
|
328
|
+
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 = '' }) {
|
|
329
|
+
// Where this machine keeps its gate approvals (0.14.4 S-1): outside the synced folder, so no peer can write one.
|
|
330
|
+
const GATES_DIR = gatesDirOpt || gatesDir()
|
|
331
|
+
const gateSkipSaid = new Set() // (agent, command) pairs already announced as unchecked
|
|
465
332
|
// BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
|
|
466
333
|
// then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
|
|
467
334
|
// and the only symptom is other people being told "@X is NOT in the room" about someone who is
|
|
@@ -753,6 +620,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
753
620
|
})
|
|
754
621
|
} catch { /* provenance must never break the sync path */ }
|
|
755
622
|
}
|
|
623
|
+
// NOTHING THIS CLIENT PUBLISHES ENTERS THE ROOM UNSIGNED (0.14.3 P0-3). With the fork gate on, a
|
|
624
|
+
// local edit is signed by captureLocal, which needs a baseline (`shadow`) to diff against. An edit
|
|
625
|
+
// made before the file first synced on a fresh join has none, and the rejoin and creation-race
|
|
626
|
+
// merges publish without passing through it at all. The content still landed, so the file's chain
|
|
627
|
+
// broke: `pr` then refused with a false Q506 ("the branch holds content this room has never seen")
|
|
628
|
+
// on a branch nobody had touched, and credited the file to the last agent that HAD signed (8-9 Nov
|
|
629
|
+
// mock run, test.js in demo-3). So after any local publish: if no receipt yields exactly what was
|
|
630
|
+
// published, sign it now, from the room's copy it replaced -- the state the room provably held.
|
|
631
|
+
// Only when THIS client's own edit went out unsigned: if captureLocal signed the authored text (or
|
|
632
|
+
// anyone signed what was published), nothing is added, so the fork gate sees exactly what it saw
|
|
633
|
+
// before. A receipt for a merged result is never added beside a signed edit: detectFork stops at a
|
|
634
|
+
// current text that has a receipt, and that must not hide a real fork.
|
|
635
|
+
function signPublished(relPath, roomBefore, published, authored = published) {
|
|
636
|
+
if (!PROVENANCE || !identity || published == null || published === roomBefore) return
|
|
637
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.ledger) return
|
|
638
|
+
const hp = contentHash(published), ha = authored == null ? null : contentHash(authored)
|
|
639
|
+
const rc = fe.ledger.toArray()
|
|
640
|
+
if (rc.some((r) => r && (r.contentHash === hp || (ha && r.author === identity.id && r.contentHash === ha)))) return
|
|
641
|
+
stampIntent(relPath, roomBefore == null ? '' : roomBefore, published)
|
|
642
|
+
}
|
|
756
643
|
function attestHead(relPath, landed, base) {
|
|
757
644
|
if (!PROVENANCE || !identity || landed == null) return
|
|
758
645
|
const fe = fileDocs.get(relPath); if (!fe || !fe.head) return
|
|
@@ -940,7 +827,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
940
827
|
// Carry each side's SIGNED intent into the announcement: the WHY is what lets any
|
|
941
828
|
// agent in the room reconcile the fork (via hive_resolve) instead of picking a winner.
|
|
942
829
|
const why = rec.versions.map((v) => `${v.name}: "${versionIntent(fe, rec, v.hash) || 'no stated intent'}"`).join(' | ')
|
|
943
|
-
try {
|
|
830
|
+
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 { }
|
|
944
831
|
}
|
|
945
832
|
}
|
|
946
833
|
// A resolution landed elsewhere: the shared text is clean again. Adopt it to disk.
|
|
@@ -1052,6 +939,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1052
939
|
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
|
|
1053
940
|
if (intent) myIntents.set(relPath, String(intent)) // my declared WHY — what my receipt will sign
|
|
1054
941
|
const full = path.join(ROOT, relPath)
|
|
942
|
+
// Written before this file synced: remember what it replaced, the edit's real ancestor (P0-1b).
|
|
943
|
+
// The OLDEST text this folder had since the file started waiting (diskAtOpen) when there is one: an
|
|
944
|
+
// earlier edit with the agent's own file tool sits between it and this write, and the room can only
|
|
945
|
+
// have held the older text (0.14.3 P0-1b-R3).
|
|
946
|
+
{ const fe0 = fileDocs.get(relPath); if (!(fe0 && fe0.synced) && !preSyncBase.has(relPath)) { try { preSyncBase.set(relPath, fe0 && typeof fe0.diskAtOpen === 'string' ? fe0.diskAtOpen : readDiskText(full)) } catch { } } }
|
|
1055
947
|
// An authored write keeps this disk's ending for the file, like every engine write (readText).
|
|
1056
948
|
try { fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, crlfSet().has(relPath) ? toLF(content).replace(/\n/g, '\r\n') : content) }
|
|
1057
949
|
catch (e) { return { error: 'write failed: ' + e.message } }
|
|
@@ -1061,7 +953,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1061
953
|
if (FORK_GATE) captureLocal(relPath)
|
|
1062
954
|
reconcile(relPath, 'local')
|
|
1063
955
|
const fe = fileDocs.get(relPath)
|
|
1064
|
-
|
|
956
|
+
// The room's copy is known only once the file synced; before that, say nothing about it rather
|
|
957
|
+
// than echo the content back as if it had landed (0.14.3 P0-1). hive_edit asks whenSent().
|
|
958
|
+
const landed = fe && fe.synced ? fe.text.toString() : null
|
|
1065
959
|
let disk = content; try { disk = readDiskText(full) } catch { }
|
|
1066
960
|
// A new file's doc has not synced yet when this returns, so the gate in reconcile has not run:
|
|
1067
961
|
// judge the content here too, or the answer would be "syncing to every peer" for a file that
|
|
@@ -1231,7 +1125,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1231
1125
|
// to everybody; this is what lets a join tell another agent with the same name (refused, see
|
|
1232
1126
|
// sameNameElsewhere) from this agent a moment ago, restarting in the same folder (allowed).
|
|
1233
1127
|
const IID = crypto.createHash('sha256').update(`${os.hostname()}|${ROOT}`).digest('hex').slice(0, 12)
|
|
1234
|
-
|
|
1128
|
+
const joinedAt = Date.now()
|
|
1129
|
+
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
|
|
1235
1130
|
|
|
1236
1131
|
// ---- relay reachability, said out loud exactly once -------------------------------
|
|
1237
1132
|
// y-websocket retries forever and silently. That is the right RECOVERY behaviour and
|
|
@@ -1333,6 +1228,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1333
1228
|
// "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
|
|
1334
1229
|
// would create a second CRDT history and duplicate/garble content on rejoin).
|
|
1335
1230
|
const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
|
|
1231
|
+
// EDITS NOT YET ACCEPTED BY THE RELAY (0.14.3 P0-1). A file enters on any local change to its doc
|
|
1232
|
+
// and leaves once its channel is connected, synced and its socket holds no unsent bytes. Synced
|
|
1233
|
+
// means the sync exchange ran after the change (it carries anything the socket missed), so
|
|
1234
|
+
// "connected + synced + nothing buffered" is "the relay has it". Over HTTPS long-poll the bytes sit
|
|
1235
|
+
// in the client until the relay accepts them; an agent that left in that window lost the edit while
|
|
1236
|
+
// hive_edit said it was syncing (8-9 Nov mock run). leave() waits on this set, hive_edit reports it,
|
|
1237
|
+
// and eviction never closes a file that is still in it.
|
|
1238
|
+
const unsentLocal = new Set()
|
|
1336
1239
|
|
|
1337
1240
|
// REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
|
|
1338
1241
|
// silence. That is right for a flaky network and badly wrong for a relay that is full: on
|
|
@@ -1405,7 +1308,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1405
1308
|
`Do not trust this folder's contents meanwhile.`,
|
|
1406
1309
|
})).text
|
|
1407
1310
|
log(`[${name}] !! ${msg}`)
|
|
1408
|
-
try {
|
|
1311
|
+
try { postOnce(`notsyncing:${code || 'stuck'}`, `[!] @all ${msg}`, { observer: true, ttlMs: 10 * 60 * 1000 }) } catch { /* chat may itself be unreachable — the log still carries it */ }
|
|
1409
1312
|
}, 4000)
|
|
1410
1313
|
if (refusalTimer.unref) refusalTimer.unref()
|
|
1411
1314
|
}
|
|
@@ -1524,8 +1427,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1524
1427
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1525
1428
|
const conflict = fdoc.getMap('conflict')
|
|
1526
1429
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1527
|
-
|
|
1430
|
+
// diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
|
|
1431
|
+
// the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
|
|
1432
|
+
// is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
|
|
1433
|
+
let diskAtOpen
|
|
1434
|
+
try { const f0 = path.join(ROOT, relPath); if (fs.statSync(f0).size <= MAX_BYTES) diskAtOpen = readDiskText(f0) } catch { diskAtOpen = undefined }
|
|
1435
|
+
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen }
|
|
1528
1436
|
fileDocs.set(relPath, e)
|
|
1437
|
+
// A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
|
|
1438
|
+
fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
|
|
1529
1439
|
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1530
1440
|
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1531
1441
|
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
@@ -1547,6 +1457,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1547
1457
|
reconcile(relPath, 'remote')
|
|
1548
1458
|
detectFork(relPath)
|
|
1549
1459
|
}
|
|
1460
|
+
preSyncBase.delete(relPath) // used by the first reconcile or not at all, never by a later reopen (review R4)
|
|
1461
|
+
e.diskAtOpen = undefined
|
|
1550
1462
|
// A new file that has just landed on disk frees its place for the next one (a burst of new
|
|
1551
1463
|
// files moves at sync speed, not at the 30 s grace), and lets waiting new files in.
|
|
1552
1464
|
if (arrivedNew.has(relPath)) {
|
|
@@ -1584,6 +1496,88 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1584
1496
|
scheduleEvictRecheck()
|
|
1585
1497
|
return e
|
|
1586
1498
|
}
|
|
1499
|
+
// `await await f()` THAT NOBODY WROTE (0.14.3 P0-5-3). Two peers making the same sync -> async change
|
|
1500
|
+
// at once each insert `await ` before the same call (each merge is right on its own), and the shared
|
|
1501
|
+
// text keeps both inserts. Remove the outer one, here, before the room's copy reaches the disk:
|
|
1502
|
+
// pure deletes of the spans lang.stackedAwaits finds (AST-level, whitespace-only between, so a
|
|
1503
|
+
// comment or a string that says "await await" is never touched). On the same converged text every
|
|
1504
|
+
// peer deletes the same characters, so two peers doing it at once remove them once. Never a
|
|
1505
|
+
// whole-text diff: that would delete and re-insert everything between two such spots, and two
|
|
1506
|
+
// peers re-inserting the same middle is the fusion this engine exists to prevent. Skipped when
|
|
1507
|
+
// this folder's own copy has one (its author wrote it) and for a file this member cannot write.
|
|
1508
|
+
function dropStackedAwaits(relPath, fe, disk) {
|
|
1509
|
+
try {
|
|
1510
|
+
if (!supports(relPath) || !canWrite(relPath)) return false
|
|
1511
|
+
const lang = languageFor(relPath)
|
|
1512
|
+
if (!lang || typeof lang.stackedAwaits !== 'function') return false
|
|
1513
|
+
const t = fe.text.toString()
|
|
1514
|
+
if (!/\bawait\s+await\b/.test(t)) return false
|
|
1515
|
+
if (typeof disk === 'string' && /\bawait\s+await\b/.test(disk) && lang.stackedAwaits(disk).length) return false
|
|
1516
|
+
const spans = lang.stackedAwaits(t)
|
|
1517
|
+
if (!spans.length) return false
|
|
1518
|
+
fe.doc.transact(() => { for (let i = spans.length - 1; i >= 0; i--) fe.text.delete(spans[i][0], spans[i][1] - spans[i][0]) })
|
|
1519
|
+
log(`[${name}] ${relPath}: removed ${spans.length} stacked \`await\` left by two peers making the same change at once`)
|
|
1520
|
+
return true
|
|
1521
|
+
} catch { return false }
|
|
1522
|
+
}
|
|
1523
|
+
/** Has the relay accepted every local change to this file? (See unsentLocal.) */
|
|
1524
|
+
function isSentNow(relPath) {
|
|
1525
|
+
if (!unsentLocal.has(relPath)) return true
|
|
1526
|
+
const fe = fileDocs.get(relPath)
|
|
1527
|
+
if (!fe) return false
|
|
1528
|
+
const p = fe.provider
|
|
1529
|
+
if (!(p && p.wsconnected && p.synced && !(p.bufferedAmount > 0))) return false
|
|
1530
|
+
// Delivered is not enough: the room must HOLD what this folder has. On first contact a local edit
|
|
1531
|
+
// the room cannot prove it has seen is kept here and deliberately NOT published (and a refused
|
|
1532
|
+
// fused merge publishes nothing); calling that "sent" told an agent its work was in the room when
|
|
1533
|
+
// the room still had the old copy. A deleted file, or one this side cannot read as text, is judged
|
|
1534
|
+
// on delivery alone.
|
|
1535
|
+
// A BINARY file's content is in fe.bin, not fe.text (which is "" for it): compared as text it never
|
|
1536
|
+
// matched, so a binary this member added was never "sent" -- leave() waited its full timeout and
|
|
1537
|
+
// named it as lost, and eviction never closed it (Opus-Cloud's review of 0.14.3). The room holds
|
|
1538
|
+
// what this folder has when the bytes' sha256 is the one the room records, as reconcileBinary writes it.
|
|
1539
|
+
if (fe.bin && fe.bin.has('data')) {
|
|
1540
|
+
let bytes = null
|
|
1541
|
+
try { bytes = fs.readFileSync(path.join(ROOT, relPath)) } catch { }
|
|
1542
|
+
if (bytes && crypto.createHash('sha256').update(bytes).digest('hex') !== fe.bin.get('sha')) return false
|
|
1543
|
+
unsentLocal.delete(relPath)
|
|
1544
|
+
return true
|
|
1545
|
+
}
|
|
1546
|
+
let disk = null
|
|
1547
|
+
try { disk = readDiskText(path.join(ROOT, relPath)) } catch { }
|
|
1548
|
+
if (disk != null && !hasConflictMarkers(disk) && toLF(disk) !== toLF(fe.text.toString())) return false
|
|
1549
|
+
unsentLocal.delete(relPath)
|
|
1550
|
+
return true
|
|
1551
|
+
}
|
|
1552
|
+
/** Resolves true once the relay has this file's local changes, false if not within timeoutMs. */
|
|
1553
|
+
async function whenSent(relPath, timeoutMs = 3000) {
|
|
1554
|
+
relPath = String(relPath || '').replace(/\\/g, '/')
|
|
1555
|
+
const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
1556
|
+
for (;;) {
|
|
1557
|
+
if (isSentNow(relPath)) return true
|
|
1558
|
+
if (stopped || Date.now() >= end) return false
|
|
1559
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const roomBuffered = () => { try { return (provider.ws && provider.ws.bufferedAmount) || 0 } catch { return 0 } }
|
|
1563
|
+
// LEAVE WITHOUT LOSING WHAT WAS JUST WRITTEN. stop() tears everything down at once; leave() first
|
|
1564
|
+
// hands the pending work over (debounced disk edits, deletes, the room doc), waits until the relay
|
|
1565
|
+
// has accepted every local change -- files and chat/claims alike -- and only then stops. Whatever
|
|
1566
|
+
// did not make it within timeoutMs is NAMED in the answer instead of vanishing (0.14.3 P0-1).
|
|
1567
|
+
async function leave({ timeoutMs = 10000 } = {}) {
|
|
1568
|
+
if (stopped) return { unsent: [], roomUnsent: false }
|
|
1569
|
+
try { if (debounce) { clearTimeout(debounce); flushFsEvents() } } catch { }
|
|
1570
|
+
try { flushFirstContact() } catch { }
|
|
1571
|
+
try { settleLocalDeletes() } catch { }
|
|
1572
|
+
try { flushRoomDoc() } catch { }
|
|
1573
|
+
const end = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
1574
|
+
const pendingNow = () => [...unsentLocal].filter((r) => !isSentNow(r))
|
|
1575
|
+
while (Date.now() < end && (pendingNow().length || roomBuffered() > 0)) await new Promise((r) => setTimeout(r, 50))
|
|
1576
|
+
const unsent = pendingNow()
|
|
1577
|
+
const roomUnsent = roomBuffered() > 0
|
|
1578
|
+
stopAll()
|
|
1579
|
+
return { unsent, roomUnsent }
|
|
1580
|
+
}
|
|
1587
1581
|
function closeFile(relPath) {
|
|
1588
1582
|
const e = fileDocs.get(relPath)
|
|
1589
1583
|
if (!e) return
|
|
@@ -1675,6 +1669,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1675
1669
|
const cand = []
|
|
1676
1670
|
for (const [r, e] of fileDocs) {
|
|
1677
1671
|
if (r === keep || !e.synced) continue
|
|
1672
|
+
if (!isSentNow(r)) continue // closing it would drop an edit the relay has not accepted yet
|
|
1678
1673
|
if (conflicted.has(r)) continue
|
|
1679
1674
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1680
1675
|
if (isClaimed(r)) continue
|
|
@@ -1705,6 +1700,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1705
1700
|
const cand = []
|
|
1706
1701
|
for (const [r, e] of fileDocs) {
|
|
1707
1702
|
if (conflicted.has(r) || isClaimed(r)) continue
|
|
1703
|
+
if (unsentLocal.has(r)) continue // a local edit the relay has not accepted: closing it loses work
|
|
1708
1704
|
try { if (e.conflict && e.conflict.size) continue } catch { }
|
|
1709
1705
|
cand.push([r, touched.get(r) || 0])
|
|
1710
1706
|
}
|
|
@@ -1877,6 +1873,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1877
1873
|
// merge. Keyed by room — the same folder rejoined against a different room must not
|
|
1878
1874
|
// inherit an ancestor from unrelated history.
|
|
1879
1875
|
const priorShadow = new Map()
|
|
1876
|
+
// relPath -> the disk text an authored write (editFile) replaced while the file had not synced yet:
|
|
1877
|
+
// that write's true ancestor, used by first contact (0.14.3 P0-1b).
|
|
1878
|
+
const preSyncBase = new Map()
|
|
1880
1879
|
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
1881
1880
|
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
1882
1881
|
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
@@ -2076,6 +2075,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2076
2075
|
const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
|
|
2077
2076
|
|
|
2078
2077
|
const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
|
|
2078
|
+
const recentlyLocallyRemovedCase = new Map() // fold(r) -> { path: r, at: Date.now() }
|
|
2079
2079
|
const collided = new Map() // collided relPath -> { with: canonical relPath }
|
|
2080
2080
|
const collidedWarned = new Set() // relPath announced to room
|
|
2081
2081
|
const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
|
|
@@ -2224,7 +2224,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2224
2224
|
if (keptAs) {
|
|
2225
2225
|
log(`[${name}] local unshared file ${relPath} overwritten by room write; copy preserved in ${keptAs}`)
|
|
2226
2226
|
try {
|
|
2227
|
-
|
|
2227
|
+
note(`· ${relPath} on ${name}'s disk was ${unshared.reason} and was overwritten by a room write — local copy preserved in ${keptAs}.`)
|
|
2228
2228
|
} catch { }
|
|
2229
2229
|
}
|
|
2230
2230
|
}
|
|
@@ -2382,10 +2382,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2382
2382
|
if (kept.length) {
|
|
2383
2383
|
const one = kept.length === 1
|
|
2384
2384
|
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)}. ` +
|
|
2385
|
-
`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. ` +
|
|
2385
|
+
`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. ` +
|
|
2386
2386
|
`Don't delete a local copy to get the room's: a deletion syncs, and removes the file for everyone.`)
|
|
2387
2387
|
}
|
|
2388
|
-
|
|
2388
|
+
// What concerns THIS folder only (line endings, older copies it now has) is a note for hive_status; only
|
|
2389
|
+
// content the room has never seen is the room's business, because nobody else has it (0.14.4, C5).
|
|
2390
|
+
note(parts.join(' '))
|
|
2391
|
+
if (kept.length) { try { say([parts[0], parts[parts.length - 1]].join(' ')) } catch { } } // the kept part is pushed last
|
|
2389
2392
|
}
|
|
2390
2393
|
|
|
2391
2394
|
function reconcileBinary(relPath, origin = 'local') {
|
|
@@ -2489,7 +2492,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2489
2492
|
if (keptAs) {
|
|
2490
2493
|
log(`[${name}] local binary ${relPath} overwritten by remote write (winner: ${remoteBy || 'teammate'}); copy preserved in ${keptAs}`)
|
|
2491
2494
|
try {
|
|
2492
|
-
|
|
2495
|
+
note(`· ${relPath} updated from room (winner: ${remoteBy || 'teammate'}). Your local copy saved to ${keptAs}.`)
|
|
2493
2496
|
} catch { }
|
|
2494
2497
|
}
|
|
2495
2498
|
}
|
|
@@ -2555,8 +2558,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2555
2558
|
// (duplicated/garbled content on rejoin). So: open if needed, then defer until
|
|
2556
2559
|
// the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
|
|
2557
2560
|
fe = fileDocs.get(relPath)
|
|
2558
|
-
|
|
2559
|
-
|
|
2561
|
+
// A local change waiting for its file to sync is an unsent edit too: the sync handler sends it,
|
|
2562
|
+
// and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
|
|
2563
|
+
if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
|
|
2564
|
+
if (!fe.synced) { if (origin === 'local' && disk !== null) unsentLocal.add(relPath); return }
|
|
2560
2565
|
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2561
2566
|
// room in either direction would decide something nobody decided: the room's next change would
|
|
2562
2567
|
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
@@ -2632,6 +2637,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2632
2637
|
// clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
|
|
2633
2638
|
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
|
|
2634
2639
|
}
|
|
2640
|
+
dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
|
|
2635
2641
|
const docText = fe.text.toString()
|
|
2636
2642
|
// An empty shared doc is authoritative ONLY if we have history for this path.
|
|
2637
2643
|
// A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
|
|
@@ -2675,6 +2681,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2675
2681
|
}
|
|
2676
2682
|
if (disk === docText) {
|
|
2677
2683
|
ensureExactDiskCasing(relPath)
|
|
2684
|
+
if (!inManifest(relPath) && origin === 'local') {
|
|
2685
|
+
const key = fold(relPath)
|
|
2686
|
+
const prevCase = recentlyLocallyRemovedCase.get(key)
|
|
2687
|
+
if (prevCase && (Date.now() - prevCase.at < 60000)) {
|
|
2688
|
+
recentlyLocallyRemovedCase.delete(key)
|
|
2689
|
+
queueManifestSet(relPath)
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2678
2692
|
// CREATING a file is editing it. This branch is where a locally-authored NEW file
|
|
2679
2693
|
// lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
|
|
2680
2694
|
// runs disk and doc already agree -- so it returns below, before the markEditing() at
|
|
@@ -2806,12 +2820,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2806
2820
|
return
|
|
2807
2821
|
}
|
|
2808
2822
|
}
|
|
2809
|
-
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
2823
|
+
if (r0.text !== docText) { fe.doc.transact(() => applyDiff(yt, r0.text)); signPublished(relPath, docText, r0.text, disk) }
|
|
2810
2824
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
2811
2825
|
if (r0.conflict) conflicted.add(relPath)
|
|
2812
2826
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
2813
2827
|
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
2814
|
-
try {
|
|
2828
|
+
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 { }
|
|
2815
2829
|
return
|
|
2816
2830
|
}
|
|
2817
2831
|
// REJOIN, NOT A FIRST JOIN. If a previous session of this client wrote this file in
|
|
@@ -2819,6 +2833,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2819
2833
|
// local copy may be newer than the room's (edited while disconnected, or the room
|
|
2820
2834
|
// came back stale). This is the data-loss case that was seen live.
|
|
2821
2835
|
let anc = priorShadow.get(relPath)
|
|
2836
|
+
// An edit THIS session made through the medium before the file had synced has a known ancestor:
|
|
2837
|
+
// the disk text it replaced (0.14.3 P0-1b). Without it, a fresh folder's first contact had no
|
|
2838
|
+
// ancestor, refused to publish the edit, and a later merge wrote the room's copy back over it.
|
|
2839
|
+
// ONLY when the room held that text (Claude-cloud's review R1): a folder carrying a local change L
|
|
2840
|
+
// the room never saw, edited before sync, would otherwise merge with ancestor L, read the room as
|
|
2841
|
+
// "removed L", and delete L from disk and room. Not proven held -> no ancestor -> the frozen keep.
|
|
2842
|
+
let preSync = false
|
|
2843
|
+
// An edit made with the agent's OWN file tool before the file synced left no record (only hive_edit
|
|
2844
|
+
// writes one), so first contact had no ancestor and froze it as a kept, unsent edit (P0-1b-R3).
|
|
2845
|
+
// What the disk held when the file opened is that ancestor, under the same rule: only if the room
|
|
2846
|
+
// provably held it.
|
|
2847
|
+
if (anc === undefined && !preSyncBase.has(relPath) && typeof fe.diskAtOpen === 'string' && disk !== null && fe.diskAtOpen !== disk) {
|
|
2848
|
+
preSyncBase.set(relPath, fe.diskAtOpen)
|
|
2849
|
+
}
|
|
2850
|
+
if (anc === undefined && preSyncBase.has(relPath)) {
|
|
2851
|
+
const pre = preSyncBase.get(relPath)
|
|
2852
|
+
const held = accountFor({ rel: relPath, branchText: pre, roomText: docText, receipts: fe.ledger ? fe.ledger.toArray() : [] }).state
|
|
2853
|
+
if (held === DESCENDS || held === IDENTICAL) { anc = pre; preSync = true }
|
|
2854
|
+
}
|
|
2855
|
+
preSyncBase.delete(relPath)
|
|
2822
2856
|
// ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
|
|
2823
2857
|
// bytes BEFORE the relay accepts them. A write held behind someone else's claim
|
|
2824
2858
|
// (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
|
|
@@ -2855,7 +2889,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2855
2889
|
// the local side first so the pre-merge bytes are recoverable regardless of outcome.
|
|
2856
2890
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
2857
2891
|
const rj = icrMerge3(anc, disk, docText, relPath)
|
|
2858
|
-
if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
|
|
2892
|
+
if (rj.text !== docText) { fe.doc.transact(() => applyDiff(yt, rj.text)); signPublished(relPath, docText, rj.text, disk) }
|
|
2859
2893
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
2860
2894
|
if (rj.conflict) conflicted.add(relPath)
|
|
2861
2895
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
@@ -2864,7 +2898,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2864
2898
|
say(unsent
|
|
2865
2899
|
? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
|
|
2866
2900
|
`It has been sent again, merged with the room's copy${rj.conflict ? ', and CONFLICT markers need resolving' : ''}; if the file is still claimed, it waits for the release. Pre-merge local copy is in the file's history.`
|
|
2867
|
-
:
|
|
2901
|
+
: preSync
|
|
2902
|
+
? `${relPath}: ${name} edited it before the room's copy had loaded here — the edit was merged with the room's copy and sent${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`
|
|
2903
|
+
: `⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`)
|
|
2868
2904
|
} catch { }
|
|
2869
2905
|
return
|
|
2870
2906
|
}
|
|
@@ -2891,7 +2927,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2891
2927
|
// Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
|
|
2892
2928
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
|
|
2893
2929
|
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
2894
|
-
|
|
2930
|
+
// FROZEN, not merely kept (0.14.3 P0-1b). With bases = the local copy and nothing else, the NEXT
|
|
2931
|
+
// reconcile of this file (the watcher seeing the agent's own write, the scan, any remote change)
|
|
2932
|
+
// merged (base local, ours local, theirs room) = the room's copy, wrote it over the local edit
|
|
2933
|
+
// and told the room "kept the changes that landed in between (nothing lost)": the edit was gone
|
|
2934
|
+
// from the room AND the disk (5 of 8 poll runs on 0.14.2, tests/diag/presync-edit-race.mjs;
|
|
2935
|
+
// traced by Claude-cloud). Frozen like a kept unsent edit: nothing syncs for this file until its
|
|
2936
|
+
// author saves it again, and that save replaces the room's copy. The worst case is now "kept,
|
|
2937
|
+
// and said", never "lost".
|
|
2938
|
+
let keptAt = 0; try { keptAt = fs.statSync(full).mtimeMs } catch { }
|
|
2939
|
+
keptUnsent.set(relPath, keptAt)
|
|
2940
|
+
// Said in the room (review R5: the agent has to act on it), but ONCE PER JOIN, in the first-contact
|
|
2941
|
+
// summary below -- not one line per file in engine words. A teammate rejoining with 40 such files
|
|
2942
|
+
// filled the chat with 40 lines (9 Nov 2026; first-contact-summary-test, red since 0.14.3).
|
|
2943
|
+
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`)
|
|
2895
2944
|
noteFirstContact('kept', relPath)
|
|
2896
2945
|
return
|
|
2897
2946
|
}
|
|
@@ -2982,7 +3031,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2982
3031
|
res = { text: disk, conflict: false } // author built on the latest or cleanly rewrote — trust it
|
|
2983
3032
|
} else {
|
|
2984
3033
|
res = icrMerge3(fork, disk, docText, relPath)
|
|
2985
|
-
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
3034
|
+
reAdded = !res.conflict && res.text !== disk && res.text !== docText // we restored remote lines a stale write had dropped
|
|
3035
|
+
// ...and when the merge gives back exactly the room's copy, the local edit did NOT survive:
|
|
3036
|
+
// never call that "nothing lost" (review R2). Keep the local copy and say so.
|
|
3037
|
+
if (!res.conflict && res.text === docText && disk !== docText) {
|
|
3038
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy replaced by the room\'s copy in a merge' })
|
|
3039
|
+
try { say(`⚠ ${relPath}: ${name}'s local change did not survive the merge with the room's copy (the room's version was taken). ${name}'s copy is in the file's history.`) } catch { }
|
|
3040
|
+
}
|
|
2986
3041
|
// OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
|
|
2987
3042
|
// rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
|
|
2988
3043
|
// tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
|
|
@@ -3055,7 +3110,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3055
3110
|
// Gate ON: the author's INTENT was already captured at the source (captureLocal, top
|
|
3056
3111
|
// of this pass) — here we only advance the authority head to the landed content.
|
|
3057
3112
|
// Gate OFF (default): the proven landed-transition stamp, unchanged.
|
|
3058
|
-
if (FORK_GATE) { if (res.text !== docText) attestHead(relPath, res.text, docText) }
|
|
3113
|
+
if (FORK_GATE) { if (res.text !== docText) { signPublished(relPath, docText, res.text, disk); attestHead(relPath, res.text, docText) } }
|
|
3059
3114
|
else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
|
|
3060
3115
|
}
|
|
3061
3116
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
@@ -3068,10 +3123,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3068
3123
|
if (hasMarkers && !conflicted.has(relPath)) {
|
|
3069
3124
|
conflicted.add(relPath)
|
|
3070
3125
|
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
3071
|
-
try {
|
|
3126
|
+
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 { }
|
|
3072
3127
|
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
3073
3128
|
conflicted.delete(relPath)
|
|
3074
|
-
try {
|
|
3129
|
+
try { postOnce(`resolved:${relPath}`, `✓ conflict in ${relPath} resolved.`, { observer: origin !== 'local', clear: `conflict:${relPath}` }) } catch { }
|
|
3075
3130
|
} else if (reAdded) {
|
|
3076
3131
|
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
3077
3132
|
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
@@ -3089,7 +3144,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3089
3144
|
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
3090
3145
|
icrWarned.set(relPath, res.icrWarning)
|
|
3091
3146
|
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
3092
|
-
try {
|
|
3147
|
+
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 { }
|
|
3093
3148
|
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
3094
3149
|
icrWarned.delete(relPath)
|
|
3095
3150
|
}
|
|
@@ -3325,6 +3380,66 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3325
3380
|
if (!text) return
|
|
3326
3381
|
chat.push([{ by: name, kind, at: fmtTime(), text: String(text) }])
|
|
3327
3382
|
}
|
|
3383
|
+
// ONE LINE PER EVENT, NOT ONE PER OBSERVER (0.14.4, C5). Most system lines are said by whoever
|
|
3384
|
+
// caused them (a task, a review, a revert): one poster by nature. But an OBSERVED event -- a conflict
|
|
3385
|
+
// in a shared file, a client on another build, a divergence -- is detected by every peer's own code,
|
|
3386
|
+
// and each posted it: one conflict gave four MERGE CONFLICT lines and then four "resolved" lines
|
|
3387
|
+
// (incident I-16, #722-#725); one odd client gave a MIXED BUILDS line from every other (I-8, #822-#823).
|
|
3388
|
+
// postOnce(id, text): `id` names the event the same way on every peer. The peer that caused it posts
|
|
3389
|
+
// at once. An observer waits 1.5 s plus 1 s per rank among present engines (sorted by name) and posts
|
|
3390
|
+
// only if nobody has; the key lives in the room (`sys_events`) and expires. A peer that cannot write
|
|
3391
|
+
// the key still posts: a missed alarm hides a conflict, a duplicate is only noise.
|
|
3392
|
+
const sysEvents = doc.getMap('sys_events')
|
|
3393
|
+
const SYS_TTL_MS = 6 * 3600 * 1000
|
|
3394
|
+
const sysTimers = new Set()
|
|
3395
|
+
// What only THIS folder needs to know (a local copy kept, a file that differed at join) goes here, to
|
|
3396
|
+
// the log and hive_status, and not to the whole room (C5).
|
|
3397
|
+
const notices = []
|
|
3398
|
+
function note(text) {
|
|
3399
|
+
const t = String(text || '')
|
|
3400
|
+
if (!t) return
|
|
3401
|
+
log(`[${name}] ${t}`)
|
|
3402
|
+
notices.push({ at: Date.now(), text: t })
|
|
3403
|
+
if (notices.length > 50) notices.shift()
|
|
3404
|
+
}
|
|
3405
|
+
function sysRank() {
|
|
3406
|
+
const names = new Set([name])
|
|
3407
|
+
try {
|
|
3408
|
+
for (const st of provider.awareness.getStates().values()) {
|
|
3409
|
+
const u = st && st.user
|
|
3410
|
+
if (u && u.name && !u.listener && !u.passive && u.v === PROTOCOL_VERSION) names.add(u.name)
|
|
3411
|
+
}
|
|
3412
|
+
} catch { }
|
|
3413
|
+
return [...names].sort().indexOf(name)
|
|
3414
|
+
}
|
|
3415
|
+
function pruneSysEvents(now) {
|
|
3416
|
+
let n = 0
|
|
3417
|
+
for (const [k, v] of sysEvents.entries()) {
|
|
3418
|
+
if (++n > 500) break
|
|
3419
|
+
if (!v || now - (Number(v.at) || 0) > (Number(v.ttl) || SYS_TTL_MS)) sysEvents.delete(k)
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
function postOnce(id, text, { observer = false, ttlMs = SYS_TTL_MS, clear = null } = {}) {
|
|
3423
|
+
if (!text) return null
|
|
3424
|
+
if (observer && (passive || listener)) return null // a client nobody waits on never speaks for the room
|
|
3425
|
+
const fire = () => {
|
|
3426
|
+
try {
|
|
3427
|
+
const prev = sysEvents.get(id)
|
|
3428
|
+
if (prev && Date.now() - (Number(prev.at) || 0) < (Number(prev.ttl) || SYS_TTL_MS)) return false
|
|
3429
|
+
doc.transact(() => {
|
|
3430
|
+
sysEvents.set(id, { by: name, at: Date.now(), ttl: ttlMs })
|
|
3431
|
+
if (clear) for (const c of [].concat(clear)) sysEvents.delete(c)
|
|
3432
|
+
pruneSysEvents(Date.now())
|
|
3433
|
+
})
|
|
3434
|
+
} catch { /* fail open: the line is said even if the key could not be written */ }
|
|
3435
|
+
say(text)
|
|
3436
|
+
return true
|
|
3437
|
+
}
|
|
3438
|
+
if (!observer) return fire()
|
|
3439
|
+
const t = setTimeout(() => { sysTimers.delete(t); if (!stopped) { try { fire() } catch { } } }, 1500 + 1000 * Math.max(0, sysRank()))
|
|
3440
|
+
sysTimers.add(t)
|
|
3441
|
+
return null
|
|
3442
|
+
}
|
|
3328
3443
|
function renderChat() {
|
|
3329
3444
|
const out = [
|
|
3330
3445
|
'# Hive Chat — the live coordination channel for everyone in this room.',
|
|
@@ -3644,7 +3759,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3644
3759
|
let myEditing = null, myLastEdit = null
|
|
3645
3760
|
let editingClearTimer = null
|
|
3646
3761
|
const warnedAt = new Map() // file -> last time we warned about co-editing it
|
|
3647
|
-
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 })
|
|
3762
|
+
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 })
|
|
3648
3763
|
function markEditing(relPath) {
|
|
3649
3764
|
myEditing = { file: relPath, at: Date.now() }
|
|
3650
3765
|
myLastEdit = myEditing
|
|
@@ -3672,7 +3787,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3672
3787
|
const now = Date.now()
|
|
3673
3788
|
if (others.length && now - (warnedAt.get(relPath) || 0) > 30000) {
|
|
3674
3789
|
warnedAt.set(relPath, now)
|
|
3675
|
-
try {
|
|
3790
|
+
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
|
|
3676
3791
|
}
|
|
3677
3792
|
}
|
|
3678
3793
|
function memberList() {
|
|
@@ -3741,7 +3856,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3741
3856
|
versionWarned.add(u.name)
|
|
3742
3857
|
const theirs = u.v ? `v${u.v}` : 'an older build'
|
|
3743
3858
|
log(`[${name}] ⚠ ${u.name} is on ${theirs} (incompatible with v${PROTOCOL_VERSION}) — they won't sync correctly until everyone updates.`)
|
|
3744
|
-
try {
|
|
3859
|
+
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 { }
|
|
3745
3860
|
}
|
|
3746
3861
|
checkBuilds()
|
|
3747
3862
|
}
|
|
@@ -3755,30 +3870,69 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3755
3870
|
// - humans are not flagged: the extension and the browser panel carry no engine build
|
|
3756
3871
|
// - one side of each pair speaks (the name that sorts first), once per build it sees
|
|
3757
3872
|
const buildWarned = new Set()
|
|
3873
|
+
// ONE ALARM PER ROOM, AND ONLY FOR ANOTHER RELEASE (0.14.4, C5). The rule used to be "one side of
|
|
3874
|
+
// each pair speaks", which is still one line per PAIR: two clients on 0.14.2 each posted about the one
|
|
3875
|
+
// on 0.14.3 (#822 + #823), and a client on the same release from another commit raised the same alarm
|
|
3876
|
+
// (I-8). Now the event is the set of releases in the room: it is said once, by whoever sees it first,
|
|
3877
|
+
// naming who runs what. The same release from another commit is a note for this folder (hive_status).
|
|
3758
3878
|
function checkBuilds() {
|
|
3759
|
-
//
|
|
3760
|
-
//
|
|
3761
|
-
const
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
for (const
|
|
3765
|
-
const u =
|
|
3766
|
-
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION) continue
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
if (
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3879
|
+
// Per CONNECTION, not per name: one participant with two engines open on different builds is its own
|
|
3880
|
+
// alarm (0.14.3 Q-2: "an older connection is still open"), and a map keyed by name would hide it.
|
|
3881
|
+
const labelsOf = new Map() // name -> Set of what its engines run
|
|
3882
|
+
const add = (n, label) => { if (!labelsOf.has(n)) labelsOf.set(n, new Set()); labelsOf.get(n).add(label) }
|
|
3883
|
+
add(name, VERSION_LABEL)
|
|
3884
|
+
for (const st of provider.awareness.getStates().values()) {
|
|
3885
|
+
const u = st && st.user
|
|
3886
|
+
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION || u.listener) continue
|
|
3887
|
+
if (u.hv) add(u.name, String(u.hv))
|
|
3888
|
+
// An AI on this protocol reporting no build is an engine older than build reporting: the rollout
|
|
3889
|
+
// case, and it cannot warn itself. Humans carry no engine build (the extension, the browser panel).
|
|
3890
|
+
else if (String(u.kind || '').toLowerCase() !== 'human') add(u.name, `a build older than build reporting (before ${VERSION})`)
|
|
3891
|
+
}
|
|
3892
|
+
const older = (label) => /^a build older/.test(label)
|
|
3893
|
+
const releaseOf = (label) => (older(label) ? label : String(label).split('+')[0])
|
|
3894
|
+
const on = (label) => (older(label) ? `on ${label}` : `on hivecode ${label}`)
|
|
3895
|
+
// The same release from another commit: a note for this folder, not a room alarm.
|
|
3896
|
+
for (const [n, labels] of labelsOf) {
|
|
3897
|
+
if (n === name) continue
|
|
3898
|
+
for (const label of labels) {
|
|
3899
|
+
if (label === VERSION_LABEL || releaseOf(label) !== VERSION) continue
|
|
3900
|
+
const key = `note|${n}|${label}`
|
|
3901
|
+
if (buildWarned.has(key)) continue
|
|
3902
|
+
buildWarned.add(key)
|
|
3903
|
+
note(`${n} runs hivecode ${label}: the same release as this client (${VERSION_LABEL}), from another commit.`)
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
// One name, two engines on different builds: they write over each other's files.
|
|
3907
|
+
for (const [n, labels] of labelsOf) {
|
|
3908
|
+
if (n === name || labels.size < 2) continue
|
|
3909
|
+
const all = [...labels].sort()
|
|
3910
|
+
const id = `dual:${n}:${all.join('|')}`
|
|
3911
|
+
if (buildWarned.has(id)) continue
|
|
3912
|
+
buildWarned.add(id)
|
|
3913
|
+
log(`[${name}] ⚠ MIXED BUILDS: ${n} has engines ${all.map(on).join(' and ')}`)
|
|
3914
|
+
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 { }
|
|
3915
|
+
}
|
|
3916
|
+
// An OLDER RELEASE in the room: one alarm per participant and build, for the whole room. The event
|
|
3917
|
+
// is "X runs B, older than the newest release here": every observer sees the same newest release, so
|
|
3918
|
+
// they name the same event, and presence churn (an agent's state re-published) never re-raises it.
|
|
3919
|
+
const parts = (r) => (older(r) ? [-1] : String(r).split('.').map((x) => parseInt(x, 10) || 0))
|
|
3920
|
+
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 }
|
|
3921
|
+
const releases = new Set()
|
|
3922
|
+
for (const labels of labelsOf.values()) for (const label of labels) releases.add(releaseOf(label))
|
|
3923
|
+
if (releases.size < 2) return
|
|
3924
|
+
const newest = [...releases].sort(cmp).pop()
|
|
3925
|
+
const current = []
|
|
3926
|
+
for (const [n, labels] of labelsOf) for (const label of labels) if (releaseOf(label) === newest) current.push(`${n} ${on(label)}`)
|
|
3927
|
+
for (const [n, labels] of labelsOf) {
|
|
3928
|
+
for (const label of labels) {
|
|
3929
|
+
if (releaseOf(label) === newest) continue
|
|
3930
|
+
const id = `builds:${n}:${label}`
|
|
3931
|
+
if (buildWarned.has(id)) continue
|
|
3932
|
+
buildWarned.add(id)
|
|
3933
|
+
log(`[${name}] ⚠ MIXED BUILDS: ${n} runs ${older(label) ? label : `hivecode ${label}`}`)
|
|
3934
|
+
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 { }
|
|
3935
|
+
}
|
|
3782
3936
|
}
|
|
3783
3937
|
}
|
|
3784
3938
|
if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
|
|
@@ -3993,7 +4147,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3993
4147
|
if (!send.length) return
|
|
3994
4148
|
for (const r of send) queueManifestDelete(r)
|
|
3995
4149
|
flushRoomDoc()
|
|
4150
|
+
const now = Date.now()
|
|
4151
|
+
for (const [k, v] of recentlyLocallyRemovedCase) { if (now - v.at > 60000) recentlyLocallyRemovedCase.delete(k) }
|
|
3996
4152
|
for (const r of send) {
|
|
4153
|
+
recentlyLocallyRemovedCase.set(fold(r), { path: r, at: now })
|
|
3997
4154
|
closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
|
|
3998
4155
|
if (isCaseInsensitive) {
|
|
3999
4156
|
const cf = fold(r)
|
|
@@ -4244,6 +4401,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4244
4401
|
}
|
|
4245
4402
|
}
|
|
4246
4403
|
}
|
|
4404
|
+
recentlyLocallyRemovedCase.delete(fold(key))
|
|
4247
4405
|
pendingManifestOps.delete(key)
|
|
4248
4406
|
newRemote.delete(key); arrivedNew.delete(key) // deleted while new or queued: nothing to wait for
|
|
4249
4407
|
closeFile(key)
|
|
@@ -4657,6 +4815,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4657
4815
|
}
|
|
4658
4816
|
}
|
|
4659
4817
|
|
|
4818
|
+
function stopAll() {
|
|
4819
|
+
try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
|
|
4820
|
+
try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
|
|
4821
|
+
stopped = true
|
|
4822
|
+
for (const t of sysTimers) clearTimeout(t)
|
|
4823
|
+
sysTimers.clear()
|
|
4824
|
+
try { flushRoomDoc() } catch { }
|
|
4825
|
+
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
4826
|
+
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
4827
|
+
openQueue.length = 0
|
|
4828
|
+
openQueueSet.clear()
|
|
4829
|
+
if (scanTimer) clearTimeout(scanTimer)
|
|
4830
|
+
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4831
|
+
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
4832
|
+
collidedTimers.clear()
|
|
4833
|
+
pendingFsEvents.clear()
|
|
4834
|
+
pendingFsFullScan = false
|
|
4835
|
+
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
4836
|
+
try { watcher && watcher.close() } catch { }
|
|
4837
|
+
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
4838
|
+
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
4839
|
+
try { fileTransport.stop() } catch { }
|
|
4840
|
+
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
4841
|
+
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
4842
|
+
try { provider.destroy() } catch { }
|
|
4843
|
+
try { doc.destroy() } catch { }
|
|
4844
|
+
// A room you have left should stop colouring what your agents recall.
|
|
4845
|
+
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
4846
|
+
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
4847
|
+
// stale-PID path rather than leaving it locked.
|
|
4848
|
+
try { releaseLock() } catch { }
|
|
4849
|
+
// A final flush of the ancestor record. The debounced save may still be pending, and
|
|
4850
|
+
// losing it would make the next join see no common ancestor -- the exact condition
|
|
4851
|
+
// that produced the rejoin clobber.
|
|
4852
|
+
try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
|
|
4853
|
+
}
|
|
4854
|
+
|
|
4660
4855
|
return {
|
|
4661
4856
|
doc,
|
|
4662
4857
|
provider,
|
|
@@ -4710,6 +4905,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4710
4905
|
}
|
|
4711
4906
|
}
|
|
4712
4907
|
swarmState.set(key, val)
|
|
4908
|
+
// The session that sets a gate has chosen to run that command, so its own machine approves it
|
|
4909
|
+
// (0.14.4 S-1). Nobody else's does: a gate set here runs elsewhere only after that person approves.
|
|
4910
|
+
if (key === GATE_CMD && String(val == null ? '' : val).trim()) {
|
|
4911
|
+
try { writeApproval(room, String(val), `set by ${name} on this machine`, GATES_DIR) } catch { /* no approval, so it simply does not run here */ }
|
|
4912
|
+
}
|
|
4713
4913
|
say(`STATE TRANSITION: [${key}] is now [${val}]`)
|
|
4714
4914
|
},
|
|
4715
4915
|
getState: () => {
|
|
@@ -4839,7 +5039,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4839
5039
|
if (v.stuckLagging) return `${v.peer}:${v.bytes}b/${v.lines}L[STUCK:${v.stuckReason || 'not catching up'}]`
|
|
4840
5040
|
return `${v.peer}:${v.bytes}b/${v.lines}L`
|
|
4841
5041
|
}).join(' ')
|
|
4842
|
-
|
|
5042
|
+
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.`)
|
|
4843
5043
|
}
|
|
4844
5044
|
|
|
4845
5045
|
return {
|
|
@@ -4872,6 +5072,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4872
5072
|
}
|
|
4873
5073
|
},
|
|
4874
5074
|
say, // post a coordination message
|
|
5075
|
+
postOnce, // a system line said once per event in the room, whoever sees it (0.14.4, C5)
|
|
5076
|
+
notices: () => notices.slice(), // what only this folder needs to know (hive_status shows it)
|
|
4875
5077
|
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
4876
5078
|
decide, // approve/deny a task (owner only, if set)
|
|
4877
5079
|
complete, // mark a task done
|
|
@@ -4933,6 +5135,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
4933
5135
|
return { ok: true, gated: true, forced: true }
|
|
4934
5136
|
}
|
|
4935
5137
|
|
|
5138
|
+
// A COMMAND FROM THE ROOM IS DATA (0.14.4 S-1). It runs here only if this machine's person
|
|
5139
|
+
// approved this exact command; otherwise the hand-back goes through UNCHECKED -- never refused,
|
|
5140
|
+
// or setting a gate would be a way to stop everyone -- and that is recorded and said once.
|
|
5141
|
+
const approvedBy = gateApproved({ room, cmd: g.cmd, dir: GATES_DIR })
|
|
5142
|
+
if (!approvedBy) {
|
|
5143
|
+
gateResults.set(`${r}|${name}`, { ...verdictRecord({ region: r, by: name, cmd: g.cmd, result: null }), skipped: 'not-approved' })
|
|
5144
|
+
letGo()
|
|
5145
|
+
const said = `${name}|${g.cmd}`
|
|
5146
|
+
if (!gateSkipSaid.has(said)) { gateSkipSaid.add(said); try { say(notApprovedLine(name)) } catch { /* chat is not the point */ } }
|
|
5147
|
+
return { ok: true, gated: false, gateSkipped: 'not-approved', code: GATE_NOT_APPROVED, cmd: g.cmd, message: notApprovedMessage({ region: r, cmd: g.cmd, dir: ROOT }) }
|
|
5148
|
+
}
|
|
4936
5149
|
const result = runGate({ cmd: g.cmd, cwd: ROOT, timeoutMs: g.timeoutMs })
|
|
4937
5150
|
gateResults.set(`${r}|${name}`, verdictRecord({ region: r, by: name, cmd: g.cmd, result }))
|
|
4938
5151
|
if (!result.ok) {
|
|
@@ -5013,7 +5226,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5013
5226
|
const u = s.user
|
|
5014
5227
|
if (!u || !u.name) continue
|
|
5015
5228
|
const prev = seen.get(u.name) || {}
|
|
5016
|
-
|
|
5229
|
+
const hv = (prev.hv === VERSION_LABEL || u.hv === VERSION_LABEL)
|
|
5230
|
+
? VERSION_LABEL
|
|
5231
|
+
: ((prev.listener && !u.listener && u.hv) || (!prev.listener && u.listener && prev.hv) || u.hv || prev.hv)
|
|
5232
|
+
seen.set(u.name, {
|
|
5233
|
+
...prev,
|
|
5234
|
+
...u,
|
|
5235
|
+
hv,
|
|
5236
|
+
editing: u.editing || prev.editing,
|
|
5237
|
+
owner: u.owner || prev.owner,
|
|
5238
|
+
passive: !!(u.passive || prev.passive),
|
|
5239
|
+
listener: !!(u.listener || prev.listener) || undefined,
|
|
5240
|
+
app: u.app || prev.app || undefined
|
|
5241
|
+
})
|
|
5017
5242
|
}
|
|
5018
5243
|
return [...seen.values()]
|
|
5019
5244
|
},
|
|
@@ -5034,7 +5259,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5034
5259
|
if (id === me || !u || !u.name || String(u.name).toLowerCase() !== lc) continue
|
|
5035
5260
|
if (u.listener || u.iid === IID) continue
|
|
5036
5261
|
if (kind === 'human' && u.kind === 'human') continue
|
|
5037
|
-
out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app })
|
|
5262
|
+
out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app, joined: u.joined, clientID: id })
|
|
5038
5263
|
}
|
|
5039
5264
|
return out
|
|
5040
5265
|
},
|
|
@@ -5140,40 +5365,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
5140
5365
|
connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
|
|
5141
5366
|
_openTurns: () => openTurns,
|
|
5142
5367
|
_scanCount: () => scanCount,
|
|
5143
|
-
stop:
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
stopped = true
|
|
5147
|
-
try { flushRoomDoc() } catch { }
|
|
5148
|
-
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
5149
|
-
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
5150
|
-
openQueue.length = 0
|
|
5151
|
-
openQueueSet.clear()
|
|
5152
|
-
if (scanTimer) clearTimeout(scanTimer)
|
|
5153
|
-
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
5154
|
-
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
5155
|
-
collidedTimers.clear()
|
|
5156
|
-
pendingFsEvents.clear()
|
|
5157
|
-
pendingFsFullScan = false
|
|
5158
|
-
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
5159
|
-
try { watcher && watcher.close() } catch { }
|
|
5160
|
-
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
5161
|
-
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
5162
|
-
try { fileTransport.stop() } catch { }
|
|
5163
|
-
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
5164
|
-
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
5165
|
-
try { provider.destroy() } catch { }
|
|
5166
|
-
try { doc.destroy() } catch { }
|
|
5167
|
-
// A room you have left should stop colouring what your agents recall.
|
|
5168
|
-
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
5169
|
-
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
5170
|
-
// stale-PID path rather than leaving it locked.
|
|
5171
|
-
try { releaseLock() } catch { }
|
|
5172
|
-
// A final flush of the ancestor record. The debounced save may still be pending, and
|
|
5173
|
-
// losing it would make the next join see no common ancestor -- the exact condition
|
|
5174
|
-
// that produced the rejoin clobber.
|
|
5175
|
-
try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
|
|
5176
|
-
},
|
|
5368
|
+
stop: stopAll,
|
|
5369
|
+
leave, // async: hand pending work over, wait for the relay to accept it, then stop (0.14.3 P0-1)
|
|
5370
|
+
whenSent, // async (file, ms): has the relay accepted this file's local changes yet?
|
|
5177
5371
|
}
|
|
5178
5372
|
}
|
|
5179
5373
|
|