hivecode-mcp 0.14.4 → 0.14.6

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/README.md CHANGED
@@ -16,7 +16,7 @@ earlier point.
16
16
  ## How it works
17
17
 
18
18
  1. Someone creates a **room** and gets a **join link**, like
19
- `wss://hivecode-relay.onrender.com|room-a1b2c3d4e5`
19
+ `wss://livecode-xoss.onrender.com|room-a1b2c3d4e5`
20
20
  2. Everyone — people and agents — joins with that link.
21
21
  3. Each agent works in **its own folder**. Hivecode keeps every folder identical, live.
22
22
  4. An agent **claims** a file before editing it and **releases** it after. While someone
@@ -88,7 +88,7 @@ Then:
88
88
  Customizations → MCP Servers → refresh. For Claude Code, start a new session.
89
89
  2. **Paste this to your agent**, with the link you were sent and a name:
90
90
 
91
- > Use your hive_join tool to join the Hivecode room `wss://hivecode-relay.onrender.com|room-a1b2c3d4e5`
91
+ > Use your hive_join tool to join the Hivecode room `wss://livecode-xoss.onrender.com|room-a1b2c3d4e5`
92
92
  > as **Alice-AI**. Then follow the rules it gives you.
93
93
 
94
94
  The agent's files go in `~/hivecode/<room>/<name>`, a folder of its own. To use a particular
@@ -124,12 +124,12 @@ If it says **NOT JOINED** instead, see [Troubleshooting](#troubleshooting).
124
124
 
125
125
  You don't need to install anything to watch and steer.
126
126
 
127
- 1. Open the Control Room: **https://hivecode-relay.onrender.com/control**
127
+ 1. Open the Control Room: **https://livecode-xoss.onrender.com/control**
128
128
  2. Paste the join link, type your name, and press **Enter room**.
129
129
 
130
130
  To skip the pasting, put the link after a `#` in the address — the room is filled in and you
131
131
  only type your name:
132
- `https://hivecode-relay.onrender.com/control#wss://hivecode-relay.onrender.com|room-a1b2c3d4e5`
132
+ `https://livecode-xoss.onrender.com/control#wss://livecode-xoss.onrender.com|room-a1b2c3d4e5`
133
133
 
134
134
  From there you can:
135
135
 
@@ -366,7 +366,7 @@ you prompt it: tell it to check the room.
366
366
  | `setup` printed `FAILED` for an app | that app's config could not be written, or what was written does not start | the reason is on the same line, and the entry to add by hand is printed below it |
367
367
  | `npx` stops with `ENOENT` and `AppData\Roaming\npm` (Windows) | that folder is missing, which happens on some fresh Node installs, and npm gives up before Hivecode starts | create it with `mkdir "$env:APPDATA\npm"` in PowerShell (`mkdir "%APPDATA%\npm"` in cmd), then run setup again |
368
368
  | `'room-…' is not recognized` or `command not found: room-…` | a join link was pasted into a terminal without quotes | give the link to your agent instead; if you pass it to `setup`, quote it |
369
- | `NOT JOINED` and the address `livecode-xoss.onrender.com` | an old Hivecode version, still cached by `npx` | put `hivecode-mcp@latest` in your agent's config and restart it |
369
+ | `NOT JOINED` and the address `hivecode-relay.onrender.com` | an old Hivecode version, still cached by `npx` | put `hivecode-mcp@latest` in your agent's config and restart it |
370
370
  | `Q430` | the relay is out of sockets | try again shortly; clients on the older one-socket-per-file transport (the VS Code extension, the browser panel) can lower `HIVE_MAX_OPEN_FILES` |
371
371
  | `Q438` | the relay holds as many open files as it is sized for | try again shortly, or ask whoever runs the relay to raise `HIVE_MAX_CHANNELS_TOTAL` |
372
372
  | `Q431` | this room is full | fewer participants in one room, or ask whoever runs the relay to raise `HIVE_SEATS` |
package/core.js CHANGED
@@ -11,6 +11,8 @@ export const {
11
11
  merge3,
12
12
  summarizeChange,
13
13
  changedRange,
14
+ lineHunks,
15
+ blockVersions,
14
16
  symbolsIn,
15
17
  icrMerge3,
16
18
  createReconciler,
package/docs/MCP.md CHANGED
@@ -16,7 +16,7 @@ the runtime copy that `setup` installs:
16
16
  "hivecode": {
17
17
  "command": "node",
18
18
  "args": ["<your home>/.hivecode/runtime/<version>/node_modules/hivecode-mcp/hive-mcp.js"],
19
- "env": { "HIVE_RELAY": "wss://hivecode-relay.onrender.com" }
19
+ "env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
20
20
  }
21
21
  }
22
22
  }
@@ -110,24 +110,24 @@ Once `slack check` passes, you can also run `--dry-run` on `slack start` or `git
110
110
  **macOS / Linux (bash, zsh):**
111
111
  ```bash
112
112
  # 1. Verify token and channel membership first
113
- SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --dry-run
113
+ SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --dry-run
114
114
 
115
115
  # 2. Start the live bridge
116
- SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789
116
+ SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789
117
117
  ```
118
118
 
119
119
  **Windows (PowerShell):**
120
120
  ```powershell
121
121
  $env:SLACK_BOT_TOKEN="xoxb-…"
122
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --dry-run
123
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789
122
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --dry-run
123
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789
124
124
  ```
125
125
 
126
126
  **Windows (Command Prompt / `cmd.exe`):**
127
127
  ```cmd
128
128
  set SLACK_BOT_TOKEN=xoxb-…
129
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --dry-run
130
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789
129
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --dry-run
130
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789
131
131
  ```
132
132
 
133
133
  ---
@@ -284,14 +284,14 @@ What to know about polling:
284
284
 
285
285
  macOS / Linux (bash, zsh):
286
286
  ```bash
287
- SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "wss://hivecode-relay.onrender.com|room-abc123" --channel D0123456789 --allow U012ABCDEF
287
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack agent "wss://livecode-xoss.onrender.com|room-abc123" --channel D0123456789 --allow U012ABCDEF
288
288
  ```
289
289
 
290
290
  Windows (PowerShell):
291
291
  ```powershell
292
292
  $env:SLACK_BOT_TOKEN="xoxb-…"
293
293
  $env:SLACK_APP_TOKEN="xapp-…"
294
- npx -y hivecode-mcp@latest slack agent "wss://hivecode-relay.onrender.com|room-abc123" --channel D0123456789 --allow U012ABCDEF
294
+ npx -y hivecode-mcp@latest slack agent "wss://livecode-xoss.onrender.com|room-abc123" --channel D0123456789 --allow U012ABCDEF
295
295
  ```
296
296
 
297
297
  <details>
@@ -366,24 +366,24 @@ Paste the contents of [`slack-app-manifest-agent.json`](../slack-app-manifest-ag
366
366
 
367
367
  **macOS / Linux (bash, zsh):**
368
368
  ```bash
369
- SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
370
- SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
369
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
370
+ SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
371
371
  ```
372
372
 
373
373
  **Windows (PowerShell):**
374
374
  ```powershell
375
375
  $env:SLACK_BOT_TOKEN="xoxb-…"
376
376
  $env:SLACK_APP_TOKEN="xapp-…"
377
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
378
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
377
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
378
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
379
379
  ```
380
380
 
381
381
  **Windows (Command Prompt / `cmd.exe`):**
382
382
  ```cmd
383
383
  set SLACK_BOT_TOKEN=xoxb-…
384
384
  set SLACK_APP_TOKEN=xapp-…
385
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
386
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
385
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
386
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
387
387
  ```
388
388
 
389
389
  ### Slackbot Remote MCP Server (`hive-mcp-http.js`)
@@ -400,14 +400,14 @@ If you connect Slackbot's remote MCP client over Streamable HTTP (`hive-mcp-http
400
400
 
401
401
  **macOS / Linux (bash, zsh):**
402
402
  ```bash
403
- SLACK_SIGNING_SECRET=… SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack mcp-server "wss://hivecode-relay.onrender.com|room-abc123" --port 8790 --allow U012ABCDEF
403
+ SLACK_SIGNING_SECRET=… SLACK_BOT_TOKEN=xoxb-… npx -y hivecode-mcp@latest slack mcp-server "wss://livecode-xoss.onrender.com|room-abc123" --port 8790 --allow U012ABCDEF
404
404
  ```
405
405
 
406
406
  **Windows (PowerShell):**
407
407
  ```powershell
408
408
  $env:SLACK_SIGNING_SECRET="…"
409
409
  $env:SLACK_BOT_TOKEN="xoxb-…"
410
- npx -y hivecode-mcp@latest slack mcp-server "wss://hivecode-relay.onrender.com|room-abc123" --port 8790 --allow U012ABCDEF
410
+ npx -y hivecode-mcp@latest slack mcp-server "wss://livecode-xoss.onrender.com|room-abc123" --port 8790 --allow U012ABCDEF
411
411
  ```
412
412
 
413
413
  **What a First-Time User Sees Step by Step (`hive-mcp-http.js`):**
@@ -447,8 +447,8 @@ Use these 5 steps to demonstrate either **GitHub-to-Slack issue threads** (`gith
447
447
  ```powershell
448
448
  $env:SLACK_BOT_TOKEN="xoxb-…"
449
449
  $env:SLACK_APP_TOKEN="xapp-…"
450
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
451
- npx -y hivecode-mcp@latest slack start "wss://hivecode-relay.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
450
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF --dry-run
451
+ npx -y hivecode-mcp@latest slack start "wss://livecode-xoss.onrender.com|room-abc123" --channel C0123456789 --allow U012ABCDEF
452
452
  ```
453
453
  4. **Trigger a Thread**:
454
454
  - **In Flow A**: Add the `hivecode` label to a GitHub issue. Watch Slack open a single thread (`#12 — Title`) for that issue and post run progress inside it.
package/hive-privacy.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // 1. A VERDICT on every join, so "am I private?" is answered without being asked.
16
16
  // Printing the URL is not a verdict. A person who has never run a relay cannot tell
17
- // wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
17
+ // wss://hivecode-relay.onrender.com from wss://relay.theircompany.com by looking.
18
18
  //
19
19
  // 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
20
20
  // an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
@@ -34,13 +34,15 @@
34
34
  // sent that client to a dead host, or made this module call the live default "YOUR RELAY".
35
35
  // Every client now imports it from here. The extension's JSON setting cannot import, so
36
36
  // extension-privacy-parity-test pins it equal to this value instead.
37
- export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
37
+ export const PUBLIC_DEFAULT_RELAY = 'wss://livecode-xoss.onrender.com'
38
38
 
39
+ // 10 Nov 2026: the hosted relay moved back to livecode-xoss (its first address, live again) when the
40
+ // hivecode-relay account ran out of hours for the month, so the two addresses below swapped places.
39
41
  // Relays this project has run publicly and retired. A .hive.json written before a move still
40
42
  // names the old one, and if that host ever answers again (a reactivated account, a squatter
41
43
  // on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
42
44
  // outside this list is called.
43
- export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
45
+ export const RETIRED_PUBLIC_RELAYS = ['wss://hivecode-relay.onrender.com']
44
46
 
45
47
  /**
46
48
  * The relay a client should actually dial for a URL it was handed.
package/icr-merge.js CHANGED
@@ -36,7 +36,8 @@ function describeConflicts(conflicts) {
36
36
  // fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
37
37
  // merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
38
38
  export function icrMerge3(base, mine, theirs, relPath) {
39
- const lm = merge3(base, mine, theirs)
39
+ // The sync merge: edits on adjacent lines that each replace lines one for one go side by side (#17).
40
+ const lm = merge3(base, mine, theirs, { sideBySide: true })
40
41
  if (!supports(relPath)) return lm
41
42
 
42
43
  let r
@@ -44,6 +45,12 @@ export function icrMerge3(base, mine, theirs, relPath) {
44
45
  catch { return lm } // ICR must never break a sync
45
46
 
46
47
  if (r.status === 'auto') {
48
+ // THE LINE MERGE KEEPS THE AUTHORS' LAYOUT (#19). A one-line top-level statement is a unit keyed by
49
+ // its own text, so the structural merge sees an edit as a delete plus an insert and can leave the
50
+ // deleted line's blank lines behind. When the line merge is clean and the two differ only in blank
51
+ // lines, the line merge is exactly both sides' edits: take it.
52
+ const blankless = (t) => t.split('\n').filter((l) => l.trim() !== '').join('\n')
53
+ if (!lm.conflict && r.text !== lm.text && blankless(r.text) === blankless(lm.text)) return lm
47
54
  const renamed = r.renames && r.renames.length
48
55
  return {
49
56
  text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
package/icr.js CHANGED
@@ -644,7 +644,9 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
644
644
  // New unit: insert it exactly where the merge order dictated
645
645
  const val = textByKey.get(k)
646
646
  if (val != null) {
647
- out += (out.endsWith('\n') ? '' : '\n') + val + '\n'
647
+ // A unit that already ends with its newline (Python, Ruby, TOML, YAML) gets no second one: that
648
+ // put a blank line no author wrote after every edited top-level line (#19).
649
+ out += (out.endsWith('\n') ? '' : '\n') + val + (val.endsWith('\n') ? '' : '\n')
648
650
  }
649
651
  }
650
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/reconcile.cjs CHANGED
@@ -47,39 +47,38 @@ function hasConflictMarkers(text) {
47
47
  }
48
48
 
49
49
  // Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
50
+ // Strip conflict markers, keeping the 'local' side (before the first =======), the 'theirs' side (after
51
+ // it), or 'all': every version in the block, in order, each distinct one once. A separator may carry a
52
+ // label ("======= Bob"): the 3-version blocks of a same-line race do.
50
53
  function stripConflictMarkers(text, prefer = 'local') {
51
54
  if (!text || !hasConflictMarkers(text)) return text || ''
52
55
  const lines = text.split('\n')
53
56
  const out = []
54
57
  let inConflict = false
55
- let inTheirs = false
56
- const localBlock = []
57
- const theirsBlock = []
58
+ let sections = []
58
59
 
59
60
  for (let i = 0; i < lines.length; i++) {
60
61
  const line = lines[i]
61
62
  if (/^<<<<<<< /.test(line)) {
62
63
  inConflict = true
63
- inTheirs = false
64
- localBlock.length = 0
65
- theirsBlock.length = 0
64
+ sections = [[]]
66
65
  continue
67
66
  }
68
- if (/^=======$/.test(line) && inConflict) {
69
- inTheirs = true
67
+ if (/^=======( |$)/.test(line) && inConflict) {
68
+ sections.push([])
70
69
  continue
71
70
  }
72
71
  if (/^>>>>>>> /.test(line) && inConflict) {
73
72
  inConflict = false
74
- out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
73
+ if (prefer === 'all') {
74
+ const seen = new Set()
75
+ for (const sec of sections) { const k = sec.join('\n'); if (seen.has(k)) continue; seen.add(k); out.push(...sec) }
76
+ } else if (prefer === 'theirs') out.push(...sections.slice(1).flat())
77
+ else out.push(...sections[0])
75
78
  continue
76
79
  }
77
- if (inConflict) {
78
- if (inTheirs) theirsBlock.push(line)
79
- else localBlock.push(line)
80
- } else {
81
- out.push(line)
82
- }
80
+ if (inConflict) sections[sections.length - 1].push(line)
81
+ else out.push(line)
83
82
  }
84
83
  return out.join('\n')
85
84
  }
@@ -193,22 +192,38 @@ function slideSpan(b, [bs, be, os, oe], src) {
193
192
  // changes conflict, as in git: two edits with no unchanged line between them are one decision). A
194
193
  // region only one side changed takes that side; both sides making the same change takes it once;
195
194
  // anything else is a conflict of THAT region only. Null when either diff is past the work bound.
196
- function diff3Lines(b, mine, theirs) {
195
+ //
196
+ // SIDE BY SIDE (sideBySide, the sync merge only; #17, 10 Nov 2026). Two edits that only TOUCH
197
+ // are applied side by side when both replace lines one for one (the same number of lines out as
198
+ // in). It is P0-F's footprint rule, and what the character CRDT does with the same two edits made at
199
+ // the same moment, so a save that lands a moment late no longer gets markers the on-time one did not
200
+ // (bigproject W1: README.md, five agents on adjacent lines). Overlap, an insertion or deletion at the
201
+ // edge, and a change of line count still conflict. git-clash and plain merge3 keep git's rule.
202
+ function diff3Lines(b, mine, theirs, sideBySide = false) {
197
203
  const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
198
204
  if (!hm || !ht) return null
199
205
  const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
206
+ const oneForOne = (h) => h[1] - h[0] === h[3] - h[2] // lineHunks has no empty hunk, so this is a real replacement
207
+ // Does a hunk starting at `s` join a region reaching `reach`? Overlap always; touching, unless both
208
+ // the hunk and every hunk ending at `reach` are one-for-one replacements (side by side).
209
+ const joins = (s, h, reach, edgeRep) => s < reach || (s === reach && !(sideBySide && edgeRep && oneForOne(h)))
200
210
  const out = []
201
211
  let conflict = false, pos = 0, i = 0, j = 0
202
212
  while (i < hm.length || j < ht.length) {
203
213
  const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
204
214
  const first = fromMine ? hm[i] : ht[j]
205
215
  let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
216
+ let edgeRep = oneForOne(first) // every hunk ending at `reach` is one-for-one
206
217
  const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
207
218
  const rs = first[0]
208
219
  let re = first[1]
220
+ const take = (h, span) => {
221
+ re = Math.max(re, h[1])
222
+ if (span[1] > reach) { reach = span[1]; edgeRep = oneForOne(h) } else if (span[1] === reach) edgeRep = edgeRep && oneForOne(h)
223
+ }
209
224
  for (;;) {
210
- if (i < hm.length && sm[i][0] <= reach) { re = Math.max(re, hm[i][1]); reach = Math.max(reach, sm[i][1]); rm.push(hm[i++]); continue }
211
- if (j < ht.length && st[j][0] <= reach) { re = Math.max(re, ht[j][1]); reach = Math.max(reach, st[j][1]); rt.push(ht[j++]); continue }
225
+ if (i < hm.length && joins(sm[i][0], hm[i], reach, edgeRep)) { take(hm[i], sm[i]); rm.push(hm[i++]); continue }
226
+ if (j < ht.length && joins(st[j][0], ht[j], reach, edgeRep)) { take(ht[j], st[j]); rt.push(ht[j++]); continue }
212
227
  break
213
228
  }
214
229
  for (let x = pos; x < rs; x++) out.push(b[x])
@@ -248,8 +263,84 @@ function summarizeChange(base, next) {
248
263
  return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
249
264
  }
250
265
 
266
+ // A LATE SAVE AGAINST A CONFLICT BLOCK (P0-F-9, 10 Nov 2026). The author edited a clean copy; the
267
+ // room's copy now holds one or more blocks. Every version the room holds is a whole file: block j
268
+ // read as section j (a block with fewer sections repeats its last). The late save is merged into
269
+ // EACH of them, a merge that conflicts giving both its sides, and the distinct results are written
270
+ // as ONE block per region where they differ. A block is never flattened into its sections side by
271
+ // side: that came out clean with lines no author wrote (`a = 1 / b = 1 / a = 2 / b = 2`, P0-F p0f),
272
+ // and 0.14.4's one-side strip silently dropped every other version. Null: the caller's old path
273
+ // (a section holding markers of its own, or a diff past its work bound).
274
+ function blockVersions(text) {
275
+ const lines = text.split('\n'), segs = [] // a segment: a plain line (string) or a block {head, foot, secs}
276
+ let blk = null
277
+ for (const line of lines) {
278
+ if (/^<<<<<<< /.test(line)) { if (blk) return null; blk = { head: line, secs: [[]] }; continue }
279
+ if (blk && /^=======( |$)/.test(line)) { blk.secs.push([]); continue }
280
+ if (blk && /^>>>>>>> /.test(line)) { blk.foot = line; segs.push(blk); blk = null; continue }
281
+ if (blk) blk.secs[blk.secs.length - 1].push(line)
282
+ else segs.push(line)
283
+ }
284
+ if (blk) return null // an unclosed block: not ours to read
285
+ const blocks = segs.filter((s) => typeof s !== 'string')
286
+ const k = Math.max(...blocks.map((b) => b.secs.length))
287
+ const versions = []
288
+ for (let j = 0; j < k; j++) versions.push(segs.flatMap((s) => (typeof s === 'string' ? [s] : s.secs[Math.min(j, s.secs.length - 1)])).join('\n'))
289
+ return { versions, blocks }
290
+ }
291
+ function mergeLateSave(base, mine, theirs, opts) {
292
+ const bv = blockVersions(theirs)
293
+ if (!bv) return null
294
+ const variants = [], lates = []
295
+ const add = (t, to = variants) => { if (!variants.some((v) => v.text === t.text) && !lates.some((v) => v.text === t.text)) to.push(t) }
296
+ for (const T of bv.versions) {
297
+ if (hasConflictMarkers(T)) return null
298
+ const r = merge3(base, mine, T, opts)
299
+ if (!r.conflict) { add({ text: r.text, mine: false }); continue }
300
+ // The room's versions first, in the block's order, so its header and footer still name its sections;
301
+ // the late save's versions join after all of them.
302
+ add({ text: stripConflictMarkers(r.text, 'theirs'), mine: false })
303
+ add({ text: stripConflictMarkers(r.text, 'local'), mine: true }, lates)
304
+ }
305
+ variants.push(...lates.filter((l) => !variants.some((v) => v.text === l.text)))
306
+ if (variants.length === 1) return { text: variants[0].text, conflict: false }
307
+ // Regions, in the first version's lines, where any other version differs.
308
+ const anchor = variants[0].text.split('\n')
309
+ const hunksOf = variants.map((v, i) => (i === 0 ? [] : lineHunks(anchor, v.text.split('\n'))))
310
+ if (hunksOf.some((h) => h === null)) return null
311
+ const spans = hunksOf.flat().map((h) => [h[0], h[1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1])
312
+ const regions = []
313
+ for (const [s, e] of spans) {
314
+ const last = regions[regions.length - 1]
315
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e)
316
+ else regions.push([s, e])
317
+ }
318
+ const heads = bv.blocks.map((b) => b.head), feet = bv.blocks.map((b) => b.foot.replace(/^>>>>>>> /, '').replace(/ \(\d+ versions\)$/, ''))
319
+ const out = []
320
+ let pos = 0
321
+ regions.forEach(([rs, re], ri) => {
322
+ for (let x = pos; x < rs; x++) out.push(anchor[x])
323
+ const secs = []
324
+ variants.forEach((v, vi) => {
325
+ const src = v.text.split('\n')
326
+ const sec = sideOfRegion(anchor, rs, re, hunksOf[vi].filter((h) => h[0] >= rs && h[1] <= re), src)
327
+ if (!secs.some((x) => x.join('\n') === sec.join('\n'))) secs.push(sec)
328
+ })
329
+ if (secs.length === 1) out.push(...secs[0])
330
+ else {
331
+ const who = feet[Math.min(ri, feet.length - 1)]
332
+ const lateName = String(opts.lateName || 'local (yours)').replace(/[\r\n]/g, ' ')
333
+ const late = variants.some((v) => v.mine) && !who.split(', ').includes(lateName) ? `, ${lateName}` : ''
334
+ out.push(heads[Math.min(ri, heads.length - 1)], ...secs[0], ...secs.slice(1).flatMap((x) => ['=======', ...x]), `>>>>>>> ${who}${late} (${secs.length} versions)`)
335
+ }
336
+ pos = re
337
+ })
338
+ for (let x = pos; x < anchor.length; x++) out.push(anchor[x])
339
+ return { text: out.join('\n'), conflict: true }
340
+ }
341
+
251
342
  // 3-way merge for the sync layer (disk <-> shared doc)
252
- function merge3(base, mine, theirs) {
343
+ function merge3(base, mine, theirs, opts = {}) {
253
344
  const norm = (s) => (s || '').replace(/\r\n/g, '\n')
254
345
  const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
255
346
  if (nm0 === nt0) return { text: nt0, conflict: false }
@@ -258,7 +349,17 @@ function merge3(base, mine, theirs) {
258
349
 
259
350
  const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
260
351
  const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
261
- const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
352
+ // A LATE SAVE: the author edited a clean copy and the room's copy now holds a conflict block. Keep
353
+ // EVERY version in the block: keeping one side dropped the other authors' work (P0-F,
354
+ // late-save-merge-test). Only then: when the base or the author's copy holds the block too, all three
355
+ // are stripped alike, as before, or the extra versions read as new lines every round and pile up
356
+ // (merge-balloon-test).
357
+ const lateSave = !hasConflictMarkers(base) && !hasConflictMarkers(mine)
358
+ if (lateSave && hasConflictMarkers(theirs)) {
359
+ const r = mergeLateSave(norm(base), norm(mine), norm(theirs), opts)
360
+ if (r) return r
361
+ }
362
+ const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, lateSave ? 'all' : 'theirs') : (theirs || '')
262
363
 
263
364
  const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
264
365
  if (nm === nt) return { text: nt, conflict: false }
@@ -266,7 +367,7 @@ function merge3(base, mine, theirs) {
266
367
  if (nb === nm) return { text: nt, conflict: false }
267
368
 
268
369
  const b = nb.split('\n')
269
- const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
370
+ const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'), !!opts.sideBySide)
270
371
  if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
271
372
  // Past diff3's work bound (two sides that share almost nothing): one range per side.
272
373
  const mr = changedRange(b, nm.split('\n'))
@@ -616,6 +717,7 @@ module.exports = {
616
717
  merge3,
617
718
  lineHunks,
618
719
  diff3Lines,
720
+ blockVersions,
619
721
  describeConflicts,
620
722
  icrMerge3
621
723
  }
package/sync.js CHANGED
@@ -24,7 +24,7 @@ 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
30
  import { RULES_MD, AGENT_MANUAL } from './hive-instructions.js'
@@ -168,6 +168,222 @@ export function fusionReason(merged, mine, theirs, lang) {
168
168
 
169
169
  const endNl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
170
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
+
171
387
  // HIV-P17. Why a creation race of relPath must NOT land, '' when it may: 'CONFLICT' when two whole
172
388
  // files collide in a file with a language, else fusionReason's answer when a clean-looking union
173
389
  // broke code both sides could parse. No language (Markdown) is never refused.
@@ -777,7 +993,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
777
993
  let real = false
778
994
  for (let i = 0; i < arr.length && !real; i++)
779
995
  for (let j = i + 1; j < arr.length && !real; j++) {
780
- 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)
781
997
  if (m.conflict || hasConflictMarkers(m.text)) real = true
782
998
  }
783
999
  if (!real) continue
@@ -1392,6 +1608,270 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1392
1608
  })
1393
1609
  }
1394
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
+
1395
1875
  function openFile(relPath) {
1396
1876
  let e = fileDocs.get(relPath)
1397
1877
  if (e) return e
@@ -1426,20 +1906,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1426
1906
  // Deterministic (computed from the ledger), converged across peers, and rendered to
1427
1907
  // each peer's DISK — never into the shared text — so conflict markers can't fuse.
1428
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')
1429
1911
  const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
1430
1912
  // diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
1431
1913
  // the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
1432
1914
  // is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
1433
1915
  let diskAtOpen
1434
1916
  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 }
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() }
1436
1918
  fileDocs.set(relPath, e)
1437
1919
  // A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
1438
1920
  fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
1439
1921
  // From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
1440
1922
  // 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
1441
1923
  if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
1442
- text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
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
1443
1929
  bin.observe((_ev, txn) => { if (!txn.local) reconcileBinary(relPath, 'remote') }) // remote binary edits
1444
1930
  // A divergent receipt can arrive slightly after (or before) the fused content — so
1445
1931
  // re-run the fork check whenever the provenance ledger changes too, not only on edits.
@@ -2562,6 +3048,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2562
3048
  // and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
2563
3049
  if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
2564
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 } }
2565
3061
  // A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
2566
3062
  // room in either direction would decide something nobody decided: the room's next change would
2567
3063
  // be written over it, and an unchanged save merged against itself pulled the room's old copy over
@@ -2639,6 +3135,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2639
3135
  }
2640
3136
  dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
2641
3137
  const docText = fe.text.toString()
3138
+ rememberText(fe, docText)
2642
3139
  // An empty shared doc is authoritative ONLY if we have history for this path.
2643
3140
  // A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
2644
3141
  // different connections — there is no atomicity across them. A client that joins a large
@@ -2820,7 +3317,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2820
3317
  return
2821
3318
  }
2822
3319
  }
2823
- 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) }
2824
3321
  if (r0.text !== disk) writeToDisk(relPath, r0.text)
2825
3322
  if (r0.conflict) conflicted.add(relPath)
2826
3323
  bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
@@ -2889,7 +3386,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
2889
3386
  // the local side first so the pre-merge bytes are recoverable regardless of outcome.
2890
3387
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
2891
3388
  const rj = icrMerge3(anc, disk, docText, relPath)
2892
- 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) }
2893
3390
  if (rj.text !== disk) writeToDisk(relPath, rj.text)
2894
3391
  if (rj.conflict) conflicted.add(relPath)
2895
3392
  bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
@@ -3005,11 +3502,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3005
3502
  }
3006
3503
  }
3007
3504
  const base = bases.has(relPath) ? bases.get(relPath) : disk
3008
- const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
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
3009
3510
  if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
3010
3511
 
3011
3512
  let res, reAdded = false
3012
- if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
3513
+ if (origin === 'local' && !deferred && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
3013
3514
  // Resolving a conflict: the doc still has <<<<<<< markers and this local
3014
3515
  // write removed them — the author has resolved it. Their clean version wins.
3015
3516
  res = { text: disk, conflict: false }
@@ -3099,7 +3600,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
3099
3600
  captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
3100
3601
  lastLocalEdit = relPath
3101
3602
  }
3102
- fileDocs.get(relPath).doc.transact(() => applyDiff(yt, res.text))
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) })
3103
3606
  // Sign this landed local change into the file's provenance ledger (I1). Stamp the
3104
3607
  // author's TRUE intent — the state THEY edited from (fork) -> the content THEY wrote
3105
3608
  // (disk) — NOT the live shared text, which under truly-concurrent edits is already