hivecode-mcp 0.11.1 → 0.11.3

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/sync.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // are auto-logged to a shared board (HIVE_BOARD.md) for other agents to read.
17
17
 
18
18
  import fs from 'fs'
19
+ import os from 'os'
19
20
  import path from 'path'
20
21
  import * as Y from 'yjs'
21
22
  import { WebsocketProvider } from 'y-websocket'
@@ -31,6 +32,7 @@ import { genIdentity, authorChange, verifyReceipt, contentHash } from './substra
31
32
  // ReferenceError and killed the sync process outright. Nothing caught it -- the client
32
33
  // simply died mid-session, which reads to a user as "sync stopped working". Rewriting a
33
34
  // whole file is the most common thing an agent does, so this fired constantly.
35
+ import grmLink from './grm-link.cjs' // hands this room's history to GRM, so the ROOM has a memory
34
36
  import { structuralMerge, supports, languageFor } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
35
37
 
36
38
  // Wire-format/protocol version. Bump when a change makes clients incompatible
@@ -48,6 +50,13 @@ const ALWAYS_IGNORE = [
48
50
  // and read position into every other agent's folder, and a shared cursor means one agent
49
51
  // consuming another's unread mail.
50
52
  '.hive-mcp-session.json',
53
+ // This client's record of what IT last wrote to each file — the common ancestor it uses
54
+ // to tell an authored edit from its own write after a restart (see SHADOW_FILE). It is
55
+ // per-client by definition: syncing it would hand every peer a false ancestor.
56
+ '.hive-shadow.json',
57
+ // Names a PID on THIS machine. Syncing it would tell every peer their folder is locked by
58
+ // a process that does not exist for them.
59
+ '.hive-lock.json',
51
60
  '*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
52
61
 
53
62
  // MOBILE AND CLOUD SECRETS. These are the files a mobile project keeps OUT of git via
@@ -75,6 +84,8 @@ const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never sync
75
84
  const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
76
85
  const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
77
86
  const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
87
+ const SHADOW_FILE = '.hive-shadow.json' // what THIS client last wrote per file; the cross-restart ancestor
88
+ const LOCK_FILE = '.hive-lock.json' // one sync client per folder; see acquireFolderLock
78
89
  const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
79
90
  const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
80
91
  const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
@@ -107,6 +118,7 @@ asked to collaborate here, follow this.
107
118
  hive_release it. If you DON'T, someone else holds it — pick other work
108
119
  (hive_claims() shows what's open). This is how agents avoid colliding.
109
120
  - hive_say("taking X: doing Y") to announce intent before you edit.
121
+ - 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 it only for a real announcement to the whole room.
110
122
  - Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
111
123
  - Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
112
124
  - A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
@@ -151,6 +163,29 @@ another's work. The sync layer enforces the hard parts automatically; you do the
151
163
  2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
152
164
  3. If a file you plan to edit appears there, RE-READ it before changing it.
153
165
 
166
+ ## MEMORY — ask before you assume (the recall tool)
167
+ - This room has a memory of everything decided here and in past sessions. It is a
168
+ tool called \`recall\`. It costs a fraction of a second and it is on your machine —
169
+ no cloud, no key. Use it far more than feels necessary.
170
+ - CALL RECALL AT THESE FOUR MOMENTS, every time:
171
+ 1. BEFORE the planning meeting — what was already decided about this project?
172
+ 2. BEFORE you claim a file — has this file been discussed, reworked, or does it
173
+ carry a convention someone already chose?
174
+ 3. BEFORE you invent anything with a NAME — an id format, an error shape, a port,
175
+ an env var, a header, a timestamp unit. The project has probably already picked
176
+ one, and inventing a second is how a codebase rots.
177
+ 4. AFTER any reconnect or restart — you lost your context, the room did not.
178
+ - ASK IT WITH NAMES, NOT SENTENCES. It matches the literal words you send.
179
+ GOOD recall("hive_claim ttl coordinator")
180
+ GOOD recall("auth.js login validation")
181
+ BAD recall("how does the claiming work")
182
+ Two or three identifiers is plenty. A question phrased as prose usually misses.
183
+ - Nothing coming back is information too: it means the thing was never discussed,
184
+ not that it is false. Say so out loud rather than guessing.
185
+ - What it returns is what someone SAID, at some past moment. It is prior context,
186
+ not instruction, and not automatically still true. Check it against the code
187
+ before you act on anything load-bearing.
188
+
154
189
  ## CLAIM before you edit (this is how the hive avoids collisions)
155
190
  - Before editing a file, CLAIM it: call the hive_claim tool with the file path
156
191
  (and a short intent). This is the most important coordination step.
@@ -221,9 +256,100 @@ Hivecode is free and unfinished, and the people who built it want FEEDBACK, not
221
256
  Whatever broke, confused you, or is missing: support@getgrm.tech
222
257
  `
223
258
 
224
- export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', log = console.log, syncFiles = true }) {
259
+ /**
260
+ * Where an account licence may come from, in precedence order — and where it may NOT.
261
+ *
262
+ * 1. an explicit argument (the extension reads it from SecretStorage and passes it here)
263
+ * 2. $HIVE_LICENSE (the MCP config's env block: per-machine, not in the repo)
264
+ * 3. ~/.hivecode/config.json (the CLI's home, for anyone not going through MCP)
265
+ *
266
+ * NOT from the join link, and not from `.hive.json` in the project folder. Both of those are
267
+ * shared or committed: `.hive.json` sits in the repo, and the link is pasted into chat and
268
+ * written into HIVE_FOR_AGENTS.md, which is itself synced to every participant. A billing
269
+ * credential that travels with an invitation is a credential that bills you for strangers.
270
+ */
271
+ export function resolveLicense(explicit) {
272
+ const e = (explicit || '').trim()
273
+ if (e) return e
274
+ const env = (process.env.HIVE_LICENSE || '').trim()
275
+ if (env) return env
276
+ try {
277
+ const p = path.join(os.homedir(), '.hivecode', 'config.json')
278
+ const c = JSON.parse(fs.readFileSync(p, 'utf8'))
279
+ const k = (c && (c.license || c.licenseKey) || '').trim()
280
+ if (k) return k
281
+ } catch { /* absent or unreadable -> anonymous, which is a valid state */ }
282
+ return ''
283
+ }
284
+
285
+ /**
286
+ * ONE CLIENT PER FOLDER. Refuses a second sync process on the same directory.
287
+ *
288
+ * Nothing prevented this before, and the identical mistake cost GRM most of a journal:
289
+ * nothing refused a second watcher on one graph, two processes appended concurrently, their
290
+ * writes interleaved, and 99% of the journal became unreadable -- while the watermark went
291
+ * on claiming the files were indexed. The failure is silent, and it is discovered much later
292
+ * by someone trying to read the data.
293
+ *
294
+ * Here the damage would be two clients with two shadow files, two fork bases and two views
295
+ * of the same disk, racing to reconcile the same paths. The CRDT would converge; the local
296
+ * files would not.
297
+ *
298
+ * Returns a release function. A lock whose PID is gone is stale and is taken over -- a
299
+ * crashed process must not lock a folder forever, which is the failure mode that makes
300
+ * people delete lock files by hand and lose the protection entirely.
301
+ */
302
+ export function acquireFolderLock(root, room, log = console.log) {
303
+ const lockPath = path.join(root, LOCK_FILE)
304
+ try {
305
+ const raw = fs.readFileSync(lockPath, 'utf8')
306
+ const prev = JSON.parse(raw)
307
+ if (prev && typeof prev.pid === 'number' && prev.pid !== process.pid) {
308
+ let alive = false
309
+ // Signal 0 checks existence without delivering anything, and works on Windows.
310
+ try { process.kill(prev.pid, 0); alive = true } catch { alive = false }
311
+ if (alive) {
312
+ const err = new Error(
313
+ `Q470 this folder is already synced by process ${prev.pid}` +
314
+ (prev.room && prev.room !== room ? ` (in room ${prev.room})` : '') +
315
+ `. Two clients on one folder corrupt each other's view of the files. ` +
316
+ `Stop that process, or use a different folder.`,
317
+ )
318
+ err.code = 'EHIVELOCKED'
319
+ err.pid = prev.pid
320
+ throw err
321
+ }
322
+ log(`[hive] taking over a stale lock from process ${prev.pid} (no longer running)`)
323
+ }
324
+ } catch (e) {
325
+ if (e && e.code === 'EHIVELOCKED') throw e
326
+ // Absent or unparseable: nothing to respect. An unreadable lock is not a reason to
327
+ // refuse to start, or a corrupted byte would brick the folder.
328
+ }
329
+ try {
330
+ fs.mkdirSync(root, { recursive: true })
331
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, room, at: Date.now() }))
332
+ } catch { /* read-only dir: proceed unlocked rather than refuse to run */ }
333
+ let released = false
334
+ const release = () => {
335
+ if (released) return
336
+ released = true
337
+ // Only remove OUR lock. Removing someone else's would hand the folder to a third
338
+ // process while the second is still writing to it.
339
+ try {
340
+ const cur = JSON.parse(fs.readFileSync(lockPath, 'utf8'))
341
+ if (cur && cur.pid === process.pid) fs.unlinkSync(lockPath)
342
+ } catch { /* already gone */ }
343
+ }
344
+ return release
345
+ }
346
+
347
+ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true }) {
225
348
  const ROOT = path.resolve(dir)
226
349
  fs.mkdirSync(ROOT, { recursive: true })
350
+ // FIRST, before any socket or file work: if another client already holds this folder,
351
+ // nothing below should happen at all.
352
+ const releaseLock = acquireFolderLock(ROOT, room, log)
227
353
 
228
354
  // Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
229
355
  // teammate's .env, keys, or build output. Reloaded by reloadIgnores().
@@ -312,7 +438,21 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
312
438
  // DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
313
439
  // whichever peer wrote last, which is precisely the failure it exists to catch.
314
440
  const swarmAttest = doc.getMap('swarm_attest')
315
- const coordinator = makeCoordinator(claims, name, { ttl: 300000 }) // 5-min claim TTL
441
+ // CLAIM LEASE. Expiry is evaluated lazily on read, so the moment a lease passes its TTL the
442
+ // next agent that tries simply gets the file -- that is what stops a stalled agent
443
+ // deadlocking the room.
444
+ //
445
+ // The 5-minute default is unchanged, but it is now overridable, because 5 minutes is longer
446
+ // than some agents work at all: on a 300s run the lease is exactly as long as the whole run,
447
+ // so the protection can never fire and a held file stays held to the end. Measured on a real
448
+ // take -- one file drew 17 claim attempts and 3 releases while eight messages asked who had it.
449
+ const CLAIM_TTL_MS = Number(process.env.HIVE_CLAIM_TTL_MS) || 300000
450
+ // How long anyone may hold a file IN TOTAL, across renewals. The ttl alone only catches a
451
+ // holder that goes SILENT; a holder that keeps working renews forever, which is how one agent
452
+ // held two files for an entire 300s run while six others waited. Default is 3x the ttl so
453
+ // existing behaviour is unchanged for anyone who does not set it.
454
+ const CLAIM_MAXHOLD_MS = Number(process.env.HIVE_CLAIM_MAXHOLD_MS) || CLAIM_TTL_MS * 3
455
+ const coordinator = makeCoordinator(claims, name, { ttl: CLAIM_TTL_MS, maxHold: CLAIM_MAXHOLD_MS })
316
456
  // MY declared intent per region — recorded on every claim ATTEMPT, kept locally.
317
457
  // The provenance stamp reads this (not the shared claim map) so an agent always signs
318
458
  // ITS OWN intent: under a true concurrent collision the shared claim converges to ONE
@@ -704,6 +844,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
704
844
  // AI agent from a human, which is otherwise invisible to it and to the operator.
705
845
  const wsParams = {}
706
846
  if (token) wsParams.token = token
847
+ // THE ACCOUNT CREDENTIAL, and it is NOT the same thing as `token` above.
848
+ //
849
+ // `token` is a room invite: it says which folders this client may write, it is short-lived,
850
+ // and it is MEANT to be shared -- it rides in the join link so an invitee can just paste a
851
+ // link. `license` says whose subscription this connection bills to, and must never be
852
+ // shared: a link pasted into a chat would bill to whoever created it, forever.
853
+ //
854
+ // They were already two separate query parameters on the relay side, and the relay has
855
+ // always read both. THE CLIENT NEVER SENT A LICENCE AT ALL -- so with
856
+ // HIVE_REQUIRE_LICENSE=true every connection from a real client was refused 402, because
857
+ // verifyLicense('') can only fail. This is the line that closes that gap.
858
+ //
859
+ // Read from the environment or an explicit argument, and DELIBERATELY not from the join
860
+ // link: see resolveLicense() for where it may come from.
861
+ const lic = resolveLicense(license)
862
+ if (lic) wsParams.license = lic
707
863
  if (name) wsParams.name = String(name).slice(0, 48)
708
864
  if (kind) wsParams.kind = kind
709
865
  const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: Object.keys(wsParams).length ? wsParams : undefined }
@@ -754,10 +910,34 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
754
910
  log(`[hive] still retrying in the background; this will say so when it reconnects.`)
755
911
  }
756
912
 
913
+ // ---- the FIRST connection, which the code above cannot cover -----------------------
914
+ // relayTrouble waits for three consecutive FAILURES. A sleeping relay does not fail: the
915
+ // socket is simply slow to be accepted, so no error event fires and nothing is reported.
916
+ // On a free-tier host that is a 30-second cold start, and the user's first ever experience
917
+ // of the product is a command that appears to hang. That is the single likeliest reason a
918
+ // first run is abandoned, and it is not an error at all -- it just needed saying.
919
+ let everConnected = false
920
+ const firstWait = setTimeout(() => {
921
+ if (everConnected) return
922
+ log(`[hive] waiting for the relay at ${relay} ...`)
923
+ log('[hive] a sleeping relay can take up to 30s to wake on the first connection.')
924
+ }, 4000)
925
+ if (firstWait.unref) firstWait.unref()
926
+ const stillWaiting = setTimeout(() => {
927
+ if (everConnected) return
928
+ log('[hive] still waiting. If this does not clear in a minute the relay may be down --')
929
+ log(`[hive] your files are safe on disk either way. ${SUPPORT} if it persists.`)
930
+ }, 35000)
931
+ if (stillWaiting.unref) stillWaiting.unref()
932
+
757
933
  provider.on('connection-error', relayTrouble)
758
934
  provider.on('connection-close', relayTrouble)
759
935
  provider.on('status', ({ status }) => {
760
936
  if (status !== 'connected') return
937
+ if (!everConnected) {
938
+ everConnected = true
939
+ clearTimeout(firstWait); clearTimeout(stillWaiting)
940
+ }
761
941
  if (relayReported) log('[hive] relay reachable again -- syncing resumed.')
762
942
  relayFails = 0
763
943
  relayReported = false
@@ -849,6 +1029,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
849
1029
  const preexisting = new Set()
850
1030
  let firstScanDone = false
851
1031
  const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
1032
+ // THE SAME THING, FROM THE PREVIOUS SESSION, and the reason it exists:
1033
+ //
1034
+ // `shadow` in memory is enough to tell an authored edit from our own write while we are
1035
+ // running. It died with the process, so after a reconnect there was no ancestor for any
1036
+ // file — every rejoin looked like FIRST CONTACT, and the adopt branch below wrote the
1037
+ // room's copy over local disk. Observed live: after an MCP drop, a STALE room copy landed
1038
+ // on top of a newer local edit, with no conflict markers and no message. The snapshot it
1039
+ // takes made that recoverable, but nothing told anyone to go looking.
1040
+ //
1041
+ // Persisted, the ancestor exists, and the case becomes what it actually is: a three-way
1042
+ // merge. Keyed by room — the same folder rejoined against a different room must not
1043
+ // inherit an ancestor from unrelated history.
1044
+ const priorShadow = new Map()
852
1045
  const bases = new Map() // last synced content per file (advances on every reconcile)
853
1046
  const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
854
1047
  // forkBases advances only on local authorship / first adoption — NOT when a
@@ -883,10 +1076,51 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
883
1076
  fs.mkdirSync(path.dirname(full), { recursive: true })
884
1077
  fs.writeFileSync(full, content)
885
1078
  shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
1079
+ saveShadowSoon() // ...and survive a restart, or the next join has no ancestor
886
1080
  known.add(relPath)
887
1081
  try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
888
1082
  }
889
1083
 
1084
+ // --- the ancestor, across restarts -----------------------------------------------------
1085
+ // Written next to writeToDisk deliberately: this file is only ever correct if it is saved
1086
+ // by the one function that changes `shadow`. Debounced, because reconcile can touch many
1087
+ // files in one pass and a synchronous write per file would show up as sync latency.
1088
+ function loadPriorShadow() {
1089
+ try {
1090
+ const o = JSON.parse(fs.readFileSync(path.join(ROOT, SHADOW_FILE), 'utf8'))
1091
+ // A mismatched room or format means we have no ancestor for these files. Returning
1092
+ // empty is the SAFE answer — it degrades to the old first-contact behaviour rather
1093
+ // than merging against someone else's history.
1094
+ if (!o || o.v !== 1 || o.room !== room) return
1095
+ for (const [k, v] of Object.entries(o.files || {})) {
1096
+ if (typeof k === 'string' && typeof v === 'string' && isSafeRelPath(k)) priorShadow.set(k, v)
1097
+ }
1098
+ log(`[hive] recovered ${priorShadow.size} file ancestor(s) from the previous session`)
1099
+ } catch { /* absent or unreadable: no ancestor, same as before this existed */ }
1100
+ }
1101
+ let shadowTimer = null
1102
+ function saveShadowSoon() {
1103
+ if (shadowTimer) return
1104
+ shadowTimer = setTimeout(() => { shadowTimer = null; saveShadow() }, 1500)
1105
+ if (shadowTimer.unref) shadowTimer.unref() // never hold the process open just to save this
1106
+ }
1107
+ function saveShadow() {
1108
+ const files = {}
1109
+ // Only what we could actually merge against later. A file over MAX_BYTES is not synced
1110
+ // in the first place, so storing it would inflate this file for no benefit.
1111
+ for (const [k, v] of shadow) if (typeof v === 'string' && v.length <= MAX_BYTES) files[k] = v
1112
+ const tmp = path.join(ROOT, SHADOW_FILE + '.tmp')
1113
+ try {
1114
+ // Write-then-rename: a half-written ancestor file is worse than none, because the
1115
+ // loader would read a truncated body as the real previous content and merge against it.
1116
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, room, files }))
1117
+ fs.renameSync(tmp, path.join(ROOT, SHADOW_FILE))
1118
+ } catch (e) {
1119
+ try { fs.unlinkSync(tmp) } catch { }
1120
+ log(`[hive] could not save the file-ancestor record: ${e.message}`)
1121
+ }
1122
+ }
1123
+
890
1124
  const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
891
1125
  const icrWarned = new Map() // relPath -> last ICR semantic warning announced (dedupe)
892
1126
  function reconcile(relPath, origin = 'local') {
@@ -977,6 +1211,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
977
1211
  const yt = fe.text
978
1212
  if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
979
1213
  if (disk === docText) {
1214
+ // CREATING a file is editing it. This branch is where a locally-authored NEW file
1215
+ // lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
1216
+ // runs disk and doc already agree -- so it returns below, before the markEditing() at
1217
+ // the end of this function. Measured effect: an agent that creates a file and moves on
1218
+ // shows as plain "online", and only an agent that writes the SAME file twice ever
1219
+ // appears to be working. In a room of agents each producing a file or two, that renders
1220
+ // a busy room as an idle one.
1221
+ // Gated on FIRST SIGHT (`known` is set just below) so the 2s safety-net scan cannot
1222
+ // re-mark an untouched file, and on local origin so a remote change landing on disk is
1223
+ // not attributed to whoever received it -- the same distinction forkBases makes here.
1224
+ if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
980
1225
  known.add(relPath); bases.set(relPath, disk); shadow.set(relPath, disk) // agreed state = capture baseline
981
1226
  // disk and doc already agree — a safe fork point, but only adopt it as
982
1227
  // THIS author's fork on first sight or our own local activity (a remote
@@ -1017,10 +1262,40 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1017
1262
  try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
1018
1263
  return
1019
1264
  }
1265
+ // REJOIN, NOT A FIRST JOIN. If a previous session of this client wrote this file in
1266
+ // this room, we DO have a common ancestor and the adopt below is simply wrong: the
1267
+ // local copy may be newer than the room's (edited while disconnected, or the room
1268
+ // came back stale). This is the data-loss case that was seen live.
1269
+ const anc = priorShadow.get(relPath)
1270
+ if (anc !== undefined && disk !== null) {
1271
+ if (disk === anc) {
1272
+ // Local is exactly what we last wrote — nobody touched it while we were away, so
1273
+ // the room's copy is strictly newer and adopting it loses nothing.
1274
+ writeToDisk(relPath, docText)
1275
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
1276
+ if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' }); snappedBase.add(relPath) }
1277
+ return
1278
+ }
1279
+ // Divergent AND we know the ancestor: merge the two, do not pick a winner. Snapshot
1280
+ // the local side first so the pre-merge bytes are recoverable regardless of outcome.
1281
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
1282
+ const rj = icrMerge3(anc, disk, docText, relPath)
1283
+ if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
1284
+ if (rj.text !== disk) writeToDisk(relPath, rj.text)
1285
+ if (rj.conflict) conflicted.add(relPath)
1286
+ bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
1287
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
1288
+ try { say(`⚠ ${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.`) } catch { }
1289
+ return
1290
+ }
1291
+ // Genuinely no ancestor (a clone-then-join). Adopt the room's copy, keep ours as a
1292
+ // restore point — and SAY SO. This branch used to be the only silent one on this
1293
+ // path, which is why the data loss went unnoticed rather than merely being rare.
1020
1294
  captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
1021
1295
  writeToDisk(relPath, docText)
1022
1296
  bases.set(relPath, docText); forkBases.set(relPath, docText)
1023
1297
  if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
1298
+ try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
1024
1299
  return
1025
1300
  }
1026
1301
  const base = bases.has(relPath) ? bases.get(relPath) : disk
@@ -1143,7 +1418,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1143
1418
  for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
1144
1419
  writeToDisk(BOARD_FILE, out.join('\n') + '\n')
1145
1420
  }
1146
- if (syncFiles) board.observe(() => renderBoard())
1421
+ if (syncFiles) board.observe(() => { renderBoard(); linkMemory() })
1147
1422
 
1148
1423
  // --- rollback: restore points + per-author revert + undo --------------------
1149
1424
  // Every meaningful change captures a RESTORE POINT: the file's content from JUST
@@ -1311,7 +1586,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1311
1586
  for (const m of chat.toArray()) out.push(`- ${m.at} ${m.by} (${m.kind}): ${m.text}`)
1312
1587
  writeToDisk(CHAT_FILE, out.join('\n') + '\n')
1313
1588
  }
1314
- if (syncFiles) chat.observe(() => renderChat())
1589
+ // Hand the room's history to GRM whenever it changes, so this ROOM has a memory: an
1590
+ // agent joining tomorrow can ask what was decided here last week, including in
1591
+ // conversations it was never present for.
1592
+ //
1593
+ // Called from the writers rather than once at join, because at join these files do not
1594
+ // exist yet -- a room only has a history after something happens in it. linkRoom()
1595
+ // compares before it writes, so calling it per message costs four stat() calls and no
1596
+ // disk write, and the relay watcher never sees needless churn.
1597
+ let memoryLinked = false
1598
+ function linkMemory() {
1599
+ if (!syncFiles) return
1600
+ const r = grmLink.linkRoom(ROOT, memoryLinked ? () => {} : log) // announce once, not per message
1601
+ if (r.linked) memoryLinked = true
1602
+ }
1603
+
1604
+ if (syncFiles) chat.observe(() => { renderChat(); linkMemory() })
1315
1605
 
1316
1606
  // --- directed work + permission ---
1317
1607
  // ASYMMETRIC gate (this is the rule that makes a hive both autonomous AND safe):
@@ -1383,15 +1673,22 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1383
1673
  // on, you post a one-time heads-up so collisions get coordinated BEFORE a clobber,
1384
1674
  // not just merged after. Activity decays after EDIT_FRESH_MS of no edits.
1385
1675
  const EDIT_FRESH_MS = 15000
1386
- let myEditing = null
1676
+ let myEditing = null, myLastEdit = null
1387
1677
  let editingClearTimer = null
1388
1678
  const warnedAt = new Map() // file -> last time we warned about co-editing it
1389
- const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, v: PROTOCOL_VERSION })
1679
+ const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION })
1390
1680
  function markEditing(relPath) {
1391
1681
  myEditing = { file: relPath, at: Date.now() }
1682
+ myLastEdit = myEditing
1392
1683
  setUserState()
1393
1684
  if (editingClearTimer) clearTimeout(editingClearTimer)
1394
- editingClearTimer = setTimeout(() => { myEditing = null; setUserState() }, EDIT_FRESH_MS) // let activity fade
1685
+ // `editing` still goes NULL at the boundary, exactly as before -- every consumer of it
1686
+ // (coEditors, HIVE_MEMBERS.md) keeps its old semantics, and the "editing" label stays
1687
+ // event-driven rather than clock-driven. `lastEdit` is purely ADDITIVE and never
1688
+ // cleared, so a panel can say "last edit 40s ago" instead of "idle". Deriving the
1689
+ // label from a timestamp instead broke hive-activity-test.js: the members file is only
1690
+ // rewritten on an event, so a render landing just before the boundary froze forever.
1691
+ editingClearTimer = setTimeout(() => { myEditing = null; setUserState() }, EDIT_FRESH_MS)
1395
1692
  }
1396
1693
  function coEditors(relPath) {
1397
1694
  const now = Date.now(), others = []
@@ -1449,7 +1746,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1449
1746
  // Snapshot the starting file set BEFORE the first reconcile pass touches anything.
1450
1747
  // Everything here pre-dates our participation; anything that shows up later was
1451
1748
  // authored while we were connected. See `preexisting`.
1452
- if (!firstScanDone) { for (const r of diskRel) preexisting.add(r); firstScanDone = true }
1749
+ // The ancestor must be loaded BEFORE any path can reach reconcile(), and this is the
1750
+ // one place guaranteed to run first exactly once. Loading it later would mean the first
1751
+ // scan's reconciles see no ancestor — precisely the bug this is here to close.
1752
+ if (!firstScanDone) { loadPriorShadow(); for (const r of diskRel) preexisting.add(r); firstScanDone = true }
1453
1753
  for (const full of diskFulls) {
1454
1754
  const r = rel(full)
1455
1755
  if (SKIP.has(r)) continue
@@ -1683,6 +1983,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1683
1983
  try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
1684
1984
  try { provider.destroy() } catch { }
1685
1985
  try { doc.destroy() } catch { }
1986
+ // A room you have left should stop colouring what your agents recall.
1987
+ if (memoryLinked) try { grmLink.unlinkRoom(ROOT, log) } catch { }
1988
+ // Last, so a crash anywhere above still frees the folder on the next run via the
1989
+ // stale-PID path rather than leaving it locked.
1990
+ try { releaseLock() } catch { }
1991
+ // A final flush of the ancestor record. The debounced save may still be pending, and
1992
+ // losing it would make the next join see no common ancestor -- the exact condition
1993
+ // that produced the rejoin clobber.
1994
+ try { if (shadowTimer) { clearTimeout(shadowTimer); shadowTimer = null } saveShadow() } catch { }
1686
1995
  },
1687
1996
  }
1688
1997
  }